feat: initial commit
All checks were successful
Cadoles/bouncer/pipeline/head This commit looks good

This commit is contained in:
2023-04-24 20:52:12 +02:00
commit e66938f1d3
134 changed files with 8507 additions and 0 deletions

101
internal/admin/authz.go Normal file
View File

@ -0,0 +1,101 @@
package admin
import (
"context"
"fmt"
"net/http"
"forge.cadoles.com/cadoles/bouncer/internal/auth"
"forge.cadoles.com/cadoles/bouncer/internal/auth/jwt"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/api"
"gitlab.com/wpetit/goweb/logger"
)
var ErrCodeForbidden api.ErrorCode = "forbidden"
func assertReadAccess(h http.Handler) http.Handler {
fn := func(w http.ResponseWriter, r *http.Request) {
reqUser, ok := assertRequestUser(w, r)
if !ok {
return
}
switch user := reqUser.(type) {
case *jwt.User:
role := user.Role()
if role == jwt.RoleReader || role == jwt.RoleWriter {
h.ServeHTTP(w, r)
return
}
default:
logUnexpectedUserType(r.Context(), reqUser)
}
forbidden(w, r)
}
return http.HandlerFunc(fn)
}
func assertWriteAccess(h http.Handler) http.Handler {
fn := func(w http.ResponseWriter, r *http.Request) {
reqUser, ok := assertRequestUser(w, r)
if !ok {
return
}
switch user := reqUser.(type) {
case *jwt.User:
role := user.Role()
if role == jwt.RoleWriter {
h.ServeHTTP(w, r)
return
}
default:
logUnexpectedUserType(r.Context(), reqUser)
}
forbidden(w, r)
}
return http.HandlerFunc(fn)
}
func assertRequestUser(w http.ResponseWriter, r *http.Request) (auth.User, bool) {
ctx := r.Context()
user, err := auth.CtxUser(ctx)
if err != nil {
logger.Error(ctx, "could not retrieve user", logger.E(errors.WithStack(err)))
forbidden(w, r)
return nil, false
}
if user == nil {
forbidden(w, r)
return nil, false
}
return user, true
}
func forbidden(w http.ResponseWriter, r *http.Request) {
logger.Warn(r.Context(), "forbidden", logger.F("path", r.URL.Path))
api.ErrorResponse(w, http.StatusForbidden, ErrCodeForbidden, nil)
}
func logUnexpectedUserType(ctx context.Context, user auth.User) {
logger.Error(
ctx, "unexpected user type",
logger.F("subject", user.Subject()),
logger.F("type", fmt.Sprintf("%T", user)),
)
}

31
internal/admin/error.go Normal file
View File

@ -0,0 +1,31 @@
package admin
import (
"fmt"
"net/http"
"forge.cadoles.com/cadoles/bouncer/internal/schema"
"gitlab.com/wpetit/goweb/api"
)
const ErrCodeAlreadyExist api.ErrorCode = "already-exist"
func invalidDataErrorResponse(w http.ResponseWriter, r *http.Request, err *schema.InvalidDataError) {
keyErrors := err.KeyErrors()
message := ""
for idx, err := range keyErrors {
if idx != 0 {
message += ", "
}
message += fmt.Sprintf("Path [%s]: %s", err.PropertyPath, err.Message)
}
api.ErrorResponse(w, http.StatusBadRequest, api.ErrCodeInvalidRequest, &struct {
Message string `json:"message"`
}{
Message: message,
})
return
}

42
internal/admin/init.go Normal file
View File

@ -0,0 +1,42 @@
package admin
import (
"context"
"forge.cadoles.com/cadoles/bouncer/internal/setup"
"github.com/pkg/errors"
)
func (s *Server) initRepositories(ctx context.Context) error {
if err := s.initLayerRepository(ctx); err != nil {
return errors.WithStack(err)
}
if err := s.initProxyRepository(ctx); err != nil {
return errors.WithStack(err)
}
return nil
}
func (s *Server) initLayerRepository(ctx context.Context) error {
layerRepository, err := setup.NewLayerRepository(ctx, s.redisConfig)
if err != nil {
return errors.WithStack(err)
}
s.layerRepository = layerRepository
return nil
}
func (s *Server) initProxyRepository(ctx context.Context) error {
proxyRepository, err := setup.NewProxyRepository(ctx, s.redisConfig)
if err != nil {
return errors.WithStack(err)
}
s.proxyRepository = proxyRepository
return nil
}

View File

@ -0,0 +1,302 @@
package admin
import (
"net/http"
"sort"
"forge.cadoles.com/cadoles/bouncer/internal/schema"
"forge.cadoles.com/cadoles/bouncer/internal/store"
"github.com/go-chi/chi/v5"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/api"
"gitlab.com/wpetit/goweb/logger"
)
type QueryLayerResponse struct {
Layers []*store.LayerHeader `json:"layers"`
}
func (s *Server) queryLayer(w http.ResponseWriter, r *http.Request) {
proxyName, ok := getProxyName(w, r)
if !ok {
return
}
options := []store.QueryLayerOptionFunc{}
name := r.URL.Query().Get("name")
if name != "" {
options = append(options, store.WithLayerQueryName(store.LayerName(name)))
}
ctx := r.Context()
layers, err := s.layerRepository.QueryLayers(
ctx,
proxyName,
options...,
)
if err != nil {
logger.Error(ctx, "could not list layers", logger.E(errors.WithStack(err)))
api.ErrorResponse(w, http.StatusInternalServerError, api.ErrCodeUnknownError, nil)
return
}
sort.Sort(store.ByLayerWeight(layers))
api.DataResponse(w, http.StatusOK, QueryLayerResponse{
Layers: layers,
})
}
func validateLayerName(v string) (store.LayerName, error) {
name, err := store.ValidateName(v)
if err != nil {
return "", errors.WithStack(err)
}
return store.LayerName(name), nil
}
type GetLayerResponse struct {
Layer *store.Layer `json:"layer"`
}
func (s *Server) getLayer(w http.ResponseWriter, r *http.Request) {
proxyName, ok := getProxyName(w, r)
if !ok {
return
}
layerName, ok := getLayerName(w, r)
if !ok {
return
}
ctx := r.Context()
layer, err := s.layerRepository.GetLayer(ctx, proxyName, layerName)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
api.ErrorResponse(w, http.StatusNotFound, api.ErrCodeNotFound, nil)
return
}
logger.Error(ctx, "could not get layer", logger.E(errors.WithStack(err)))
api.ErrorResponse(w, http.StatusInternalServerError, api.ErrCodeUnknownError, nil)
return
}
api.DataResponse(w, http.StatusOK, GetLayerResponse{
Layer: layer,
})
}
type DeleteLayerResponse struct {
LayerName store.LayerName `json:"layerName"`
}
func (s *Server) deleteLayer(w http.ResponseWriter, r *http.Request) {
proxyName, ok := getProxyName(w, r)
if !ok {
return
}
layerName, ok := getLayerName(w, r)
if !ok {
return
}
ctx := r.Context()
if err := s.layerRepository.DeleteLayer(ctx, proxyName, layerName); err != nil {
if errors.Is(err, store.ErrNotFound) {
api.ErrorResponse(w, http.StatusNotFound, api.ErrCodeNotFound, nil)
return
}
logger.Error(ctx, "could not delete layer", logger.E(errors.WithStack(err)))
api.ErrorResponse(w, http.StatusInternalServerError, api.ErrCodeUnknownError, nil)
return
}
api.DataResponse(w, http.StatusOK, DeleteLayerResponse{
LayerName: layerName,
})
}
type CreateLayerRequest struct {
Name string `json:"name" validate:"required"`
Type string `json:"type" validate:"required"`
Options map[string]any `json:"options"`
}
type CreateLayerResponse struct {
Layer *store.Layer `json:"layer"`
}
func (s *Server) createLayer(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
proxyName, ok := getProxyName(w, r)
if !ok {
return
}
createLayerReq := &CreateLayerRequest{}
if ok := api.Bind(w, r, createLayerReq); !ok {
return
}
layerName, err := store.ValidateName(createLayerReq.Name)
if err != nil {
logger.Error(r.Context(), "could not parse 'name' parameter", logger.E(errors.WithStack(err)))
api.ErrorResponse(w, http.StatusBadRequest, api.ErrCodeMalformedRequest, nil)
return
}
layer, err := s.layerRepository.CreateLayer(ctx, proxyName, store.LayerName(layerName), store.LayerType(createLayerReq.Type), createLayerReq.Options)
if err != nil {
if errors.Is(err, store.ErrAlreadyExist) {
api.ErrorResponse(w, http.StatusConflict, ErrCodeAlreadyExist, nil)
return
}
logger.Error(ctx, "could not create layer", logger.E(errors.WithStack(err)))
api.ErrorResponse(w, http.StatusInternalServerError, api.ErrCodeUnknownError, nil)
return
}
api.DataResponse(w, http.StatusOK, struct {
Layer *store.Layer `json:"layer"`
}{
Layer: layer,
})
}
type UpdateLayerRequest struct {
Enabled *bool `json:"enabled"`
Weight *int `json:"weight"`
Options *store.LayerOptions `json:"options"`
}
type UpdateLayerResponse struct {
Layer *store.Layer `json:"layer"`
}
func (s *Server) updateLayer(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
proxyName, ok := getProxyName(w, r)
if !ok {
return
}
layerName, ok := getLayerName(w, r)
if !ok {
return
}
layer, err := s.layerRepository.GetLayer(ctx, proxyName, layerName)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
api.ErrorResponse(w, http.StatusNotFound, api.ErrCodeNotFound, nil)
return
}
logger.Error(ctx, "could not get layer", logger.E(errors.WithStack(err)))
api.ErrorResponse(w, http.StatusInternalServerError, api.ErrCodeUnknownError, nil)
return
}
updateLayerReq := &UpdateLayerRequest{}
if ok := api.Bind(w, r, updateLayerReq); !ok {
return
}
options := make([]store.UpdateLayerOptionFunc, 0)
if updateLayerReq.Enabled != nil {
options = append(options, store.WithLayerUpdateEnabled(*updateLayerReq.Enabled))
}
if updateLayerReq.Weight != nil {
options = append(options, store.WithLayerUpdateWeight(*updateLayerReq.Weight))
}
if updateLayerReq.Options != nil {
if err := schema.ValidateLayerOptions(ctx, layer.Type, updateLayerReq.Options); err != nil {
logger.Error(r.Context(), "could not validate layer options", logger.E(errors.WithStack(err)))
var invalidDataErr *schema.InvalidDataError
if errors.As(err, &invalidDataErr) {
invalidDataErrorResponse(w, r, invalidDataErr)
return
}
api.ErrorResponse(w, http.StatusInternalServerError, api.ErrCodeUnknownError, nil)
return
}
options = append(options, store.WithLayerUpdateOptions(*updateLayerReq.Options))
}
layer, err = s.layerRepository.UpdateLayer(
ctx, proxyName, layerName,
options...,
)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
api.ErrorResponse(w, http.StatusNotFound, api.ErrCodeNotFound, nil)
return
}
logger.Error(ctx, "could not update layer", logger.E(errors.WithStack(err)))
api.ErrorResponse(w, http.StatusInternalServerError, api.ErrCodeUnknownError, nil)
return
}
api.DataResponse(w, http.StatusOK, UpdateLayerResponse{Layer: layer})
}
func getLayerName(w http.ResponseWriter, r *http.Request) (store.LayerName, bool) {
rawLayerName := chi.URLParam(r, "layerName")
name, err := store.ValidateName(rawLayerName)
if err != nil {
logger.Error(r.Context(), "could not parse layer name", logger.E(errors.WithStack(err)))
api.ErrorResponse(w, http.StatusBadRequest, api.ErrCodeMalformedRequest, nil)
return "", false
}
return store.LayerName(name), true
}
func geLayerName(w http.ResponseWriter, r *http.Request) (store.LayerName, bool) {
rawLayerName := chi.URLParam(r, "layerName")
name, err := store.ValidateName(rawLayerName)
if err != nil {
logger.Error(r.Context(), "could not parse layer name", logger.E(errors.WithStack(err)))
api.ErrorResponse(w, http.StatusBadRequest, api.ErrCodeMalformedRequest, nil)
return "", false
}
return store.LayerName(name), true
}

31
internal/admin/option.go Normal file
View File

@ -0,0 +1,31 @@
package admin
import (
"forge.cadoles.com/cadoles/bouncer/internal/config"
)
type Option struct {
ServerConfig config.AdminServerConfig
RedisConfig config.RedisConfig
}
type OptionFunc func(*Option)
func defaultOption() *Option {
return &Option{
ServerConfig: config.NewDefaultAdminServerConfig(),
RedisConfig: config.NewDefaultRedisConfig(),
}
}
func WithServerConfig(conf config.AdminServerConfig) OptionFunc {
return func(opt *Option) {
opt.ServerConfig = conf
}
}
func WithRedisConfig(conf config.RedisConfig) OptionFunc {
return func(opt *Option) {
opt.RedisConfig = conf
}
}

View File

@ -0,0 +1,312 @@
package admin
import (
"net/http"
"net/url"
"sort"
"strconv"
"strings"
"forge.cadoles.com/cadoles/bouncer/internal/store"
"github.com/go-chi/chi/v5"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/api"
"gitlab.com/wpetit/goweb/logger"
)
type QueryProxyResponse struct {
Proxies []*store.ProxyHeader `json:"proxies"`
}
func (s *Server) queryProxy(w http.ResponseWriter, r *http.Request) {
options := []store.QueryProxyOptionFunc{}
names, ok := getStringableSliceValues(w, r, "names", nil, validateProxyName)
if !ok {
return
}
if names != nil {
options = append(options, store.WithProxyQueryNames(names...))
}
ctx := r.Context()
proxies, err := s.proxyRepository.QueryProxy(
ctx,
options...,
)
if err != nil {
logger.Error(ctx, "could not list proxies", logger.E(errors.WithStack(err)))
api.ErrorResponse(w, http.StatusInternalServerError, api.ErrCodeUnknownError, nil)
return
}
sort.Sort(store.ByProxyWeight(proxies))
api.DataResponse(w, http.StatusOK, QueryProxyResponse{
Proxies: proxies,
})
}
func validateProxyName(v string) (store.ProxyName, error) {
name, err := store.ValidateName(v)
if err != nil {
return "", errors.WithStack(err)
}
return store.ProxyName(name), nil
}
type GetProxyResponse struct {
Proxy *store.Proxy `json:"proxy"`
}
func (s *Server) getProxy(w http.ResponseWriter, r *http.Request) {
proxyName, ok := getProxyName(w, r)
if !ok {
return
}
ctx := r.Context()
proxy, err := s.proxyRepository.GetProxy(ctx, proxyName)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
api.ErrorResponse(w, http.StatusNotFound, api.ErrCodeNotFound, nil)
return
}
logger.Error(ctx, "could not get proxy", logger.E(errors.WithStack(err)))
api.ErrorResponse(w, http.StatusInternalServerError, api.ErrCodeUnknownError, nil)
return
}
api.DataResponse(w, http.StatusOK, GetProxyResponse{
Proxy: proxy,
})
}
type DeleteProxyResponse struct {
ProxyName store.ProxyName `json:"proxyName"`
}
func (s *Server) deleteProxy(w http.ResponseWriter, r *http.Request) {
proxyName, ok := getProxyName(w, r)
if !ok {
return
}
ctx := r.Context()
if err := s.proxyRepository.DeleteProxy(ctx, proxyName); err != nil {
if errors.Is(err, store.ErrNotFound) {
api.ErrorResponse(w, http.StatusNotFound, api.ErrCodeNotFound, nil)
return
}
logger.Error(ctx, "could not delete proxy", logger.E(errors.WithStack(err)))
api.ErrorResponse(w, http.StatusInternalServerError, api.ErrCodeUnknownError, nil)
return
}
api.DataResponse(w, http.StatusOK, DeleteProxyResponse{
ProxyName: proxyName,
})
}
type CreateProxyRequest struct {
Name string `json:"name" validate:"required"`
To string `json:"to" validate:"required"`
From []string `json:"from" validate:"required"`
}
type CreateProxyResponse struct {
Proxy *store.Proxy `json:"proxy"`
}
func (s *Server) createProxy(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
createProxyReq := &CreateProxyRequest{}
if ok := api.Bind(w, r, createProxyReq); !ok {
return
}
name, err := store.ValidateName(createProxyReq.Name)
if err != nil {
logger.Error(r.Context(), "could not parse 'name' parameter", logger.E(errors.WithStack(err)))
api.ErrorResponse(w, http.StatusBadRequest, api.ErrCodeMalformedRequest, nil)
return
}
if _, err := url.Parse(createProxyReq.To); err != nil {
logger.Error(r.Context(), "could not parse 'to' parameter", logger.E(errors.WithStack(err)))
api.ErrorResponse(w, http.StatusBadRequest, api.ErrCodeMalformedRequest, nil)
return
}
proxy, err := s.proxyRepository.CreateProxy(ctx, store.ProxyName(name), createProxyReq.To, createProxyReq.From...)
if err != nil {
if errors.Is(err, store.ErrAlreadyExist) {
api.ErrorResponse(w, http.StatusConflict, ErrCodeAlreadyExist, nil)
return
}
logger.Error(ctx, "could not create proxy", logger.E(errors.WithStack(err)))
api.ErrorResponse(w, http.StatusInternalServerError, api.ErrCodeUnknownError, nil)
return
}
api.DataResponse(w, http.StatusOK, struct {
Proxy *store.Proxy `json:"proxy"`
}{
Proxy: proxy,
})
}
type UpdateProxyRequest struct {
Enabled *bool `json:"enabled"`
Weight *int `json:"weight"`
To *string `json:"to"`
From []string `json:"from"`
}
type UpdateProxyResponse struct {
Proxy *store.Proxy `json:"proxy"`
}
func (s *Server) updateProxy(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
proxyName, ok := getProxyName(w, r)
if !ok {
return
}
updateProxyReq := &UpdateProxyRequest{}
if ok := api.Bind(w, r, updateProxyReq); !ok {
return
}
options := make([]store.UpdateProxyOptionFunc, 0)
if updateProxyReq.Enabled != nil {
options = append(options, store.WithProxyUpdateEnabled(*updateProxyReq.Enabled))
}
if updateProxyReq.To != nil {
_, err := url.Parse(*updateProxyReq.To)
if err != nil {
logger.Error(r.Context(), "could not parse 'to' parameter", logger.E(errors.WithStack(err)))
api.ErrorResponse(w, http.StatusBadRequest, api.ErrCodeMalformedRequest, nil)
return
}
options = append(options, store.WithProxyUpdateTo(*updateProxyReq.To))
}
if updateProxyReq.From != nil {
options = append(options, store.WithProxyUpdateFrom(updateProxyReq.From...))
}
if updateProxyReq.Weight != nil {
options = append(options, store.WithProxyUpdateWeight(*updateProxyReq.Weight))
}
proxy, err := s.proxyRepository.UpdateProxy(
ctx, proxyName,
options...,
)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
api.ErrorResponse(w, http.StatusNotFound, api.ErrCodeNotFound, nil)
return
}
logger.Error(ctx, "could not update proxy", logger.E(errors.WithStack(err)))
api.ErrorResponse(w, http.StatusInternalServerError, api.ErrCodeUnknownError, nil)
return
}
api.DataResponse(w, http.StatusOK, UpdateProxyResponse{Proxy: proxy})
}
func getProxyName(w http.ResponseWriter, r *http.Request) (store.ProxyName, bool) {
rawProxyName := chi.URLParam(r, "proxyName")
name, err := store.ValidateName(rawProxyName)
if err != nil {
logger.Error(r.Context(), "could not parse proxy name", logger.E(errors.WithStack(err)))
api.ErrorResponse(w, http.StatusBadRequest, api.ErrCodeMalformedRequest, nil)
return "", false
}
return store.ProxyName(name), true
}
func getIntQueryParam(w http.ResponseWriter, r *http.Request, param string, defaultValue int64) (int64, bool) {
rawValue := r.URL.Query().Get(param)
if rawValue != "" {
value, err := strconv.ParseInt(rawValue, 10, 64)
if err != nil {
logger.Error(r.Context(), "could not parse int param", logger.F("param", param), logger.E(errors.WithStack(err)))
api.ErrorResponse(w, http.StatusBadRequest, api.ErrCodeMalformedRequest, nil)
return 0, false
}
return value, true
}
return defaultValue, true
}
func getStringSliceValues(w http.ResponseWriter, r *http.Request, param string, defaultValue []string) ([]string, bool) {
rawValue := r.URL.Query().Get(param)
if rawValue != "" {
values := strings.Split(rawValue, ",")
return values, true
}
return defaultValue, true
}
func getStringableSliceValues[T ~string](w http.ResponseWriter, r *http.Request, param string, defaultValue []T, validate func(string) (T, error)) ([]T, bool) {
rawValue := r.URL.Query().Get(param)
if rawValue != "" {
rawValues := strings.Split(rawValue, ",")
values := make([]T, 0, len(rawValues))
for _, rv := range rawValues {
v, err := validate(rv)
if err != nil {
logger.Error(r.Context(), "could not parse ids slice param", logger.F("param", param), logger.E(errors.WithStack(err)))
api.ErrorResponse(w, http.StatusBadRequest, api.ErrCodeMalformedRequest, nil)
return nil, false
}
values = append(values, v)
}
return values, true
}
return defaultValue, true
}

145
internal/admin/server.go Normal file
View File

@ -0,0 +1,145 @@
package admin
import (
"context"
"fmt"
"log"
"net"
"net/http"
"forge.cadoles.com/cadoles/bouncer/internal/auth"
"forge.cadoles.com/cadoles/bouncer/internal/auth/jwt"
"forge.cadoles.com/cadoles/bouncer/internal/config"
"forge.cadoles.com/cadoles/bouncer/internal/jwk"
"forge.cadoles.com/cadoles/bouncer/internal/store"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/cors"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/logger"
)
type Server struct {
serverConfig config.AdminServerConfig
redisConfig config.RedisConfig
proxyRepository store.ProxyRepository
layerRepository store.LayerRepository
}
func (s *Server) Start(ctx context.Context) (<-chan net.Addr, <-chan error) {
errs := make(chan error)
addrs := make(chan net.Addr)
go s.run(ctx, addrs, errs)
return addrs, errs
}
func (s *Server) run(parentCtx context.Context, addrs chan net.Addr, errs chan error) {
defer func() {
close(errs)
close(addrs)
}()
ctx, cancel := context.WithCancel(parentCtx)
defer cancel()
if err := s.initRepositories(ctx); err != nil {
errs <- errors.WithStack(err)
return
}
listener, err := net.Listen("tcp", fmt.Sprintf("%s:%d", s.serverConfig.HTTP.Host, s.serverConfig.HTTP.Port))
if err != nil {
errs <- errors.WithStack(err)
return
}
addrs <- listener.Addr()
defer func() {
if err := listener.Close(); err != nil && !errors.Is(err, net.ErrClosed) {
errs <- errors.WithStack(err)
}
}()
go func() {
<-ctx.Done()
if err := listener.Close(); err != nil && !errors.Is(err, net.ErrClosed) {
log.Printf("%+v", errors.WithStack(err))
}
}()
key, err := jwk.LoadOrGenerate(string(s.serverConfig.Auth.PrivateKey), jwk.DefaultKeySize)
if err != nil {
errs <- errors.WithStack(err)
return
}
keys, err := jwk.PublicKeySet(key)
if err != nil {
errs <- errors.WithStack(err)
return
}
router := chi.NewRouter()
router.Use(middleware.Logger)
corsMiddleware := cors.New(cors.Options{
AllowedOrigins: s.serverConfig.CORS.AllowedOrigins,
AllowedMethods: s.serverConfig.CORS.AllowedMethods,
AllowCredentials: bool(s.serverConfig.CORS.AllowCredentials),
AllowedHeaders: s.serverConfig.CORS.AllowedHeaders,
Debug: bool(s.serverConfig.CORS.Debug),
})
router.Use(corsMiddleware.Handler)
router.Route("/api/v1", func(r chi.Router) {
r.Group(func(r chi.Router) {
r.Use(auth.Middleware(
jwt.NewAuthenticator(keys, string(s.serverConfig.Auth.Issuer), jwt.DefaultAcceptableSkew),
))
r.Route("/proxies", func(r chi.Router) {
r.With(assertReadAccess).Get("/", s.queryProxy)
r.With(assertWriteAccess).Post("/", s.createProxy)
r.With(assertReadAccess).Get("/{proxyName}", s.getProxy)
r.With(assertWriteAccess).Put("/{proxyName}", s.updateProxy)
r.With(assertWriteAccess).Delete("/{proxyName}", s.deleteProxy)
r.With(assertReadAccess).Get("/{proxyName}/layers", s.queryLayer)
r.With(assertWriteAccess).Post("/{proxyName}/layers", s.createLayer)
r.With(assertReadAccess).Get("/{proxyName}/layers/{layerName}", s.getLayer)
r.With(assertWriteAccess).Put("/{proxyName}/layers/{layerName}", s.updateLayer)
r.With(assertWriteAccess).Delete("/{proxyName}/layers/{layerName}", s.deleteLayer)
})
})
})
logger.Info(ctx, "http server listening")
if err := http.Serve(listener, router); err != nil && !errors.Is(err, net.ErrClosed) {
errs <- errors.WithStack(err)
}
logger.Info(ctx, "http server exiting")
}
func NewServer(funcs ...OptionFunc) *Server {
opt := defaultOption()
for _, fn := range funcs {
fn(opt)
}
return &Server{
serverConfig: opt.ServerConfig,
redisConfig: opt.RedisConfig,
}
}

View File

@ -0,0 +1,72 @@
package jwt
import (
"context"
"net/http"
"strings"
"time"
"forge.cadoles.com/cadoles/bouncer/internal/auth"
"forge.cadoles.com/cadoles/bouncer/internal/jwk"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/logger"
)
const DefaultAcceptableSkew = 5 * time.Minute
type Authenticator struct {
keys jwk.Set
issuer string
acceptableSkew time.Duration
}
// Authenticate implements auth.Authenticator.
func (a *Authenticator) Authenticate(ctx context.Context, r *http.Request) (auth.User, error) {
ctx = logger.With(r.Context(), logger.F("remoteAddr", r.RemoteAddr))
authorization := r.Header.Get("Authorization")
if authorization == "" {
return nil, errors.WithStack(auth.ErrUnauthenticated)
}
rawToken := strings.TrimPrefix(authorization, "Bearer ")
if rawToken == "" {
return nil, errors.WithStack(auth.ErrUnauthenticated)
}
token, err := parseToken(ctx, a.keys, a.issuer, rawToken, a.acceptableSkew)
if err != nil {
return nil, errors.WithStack(err)
}
rawRole, exists := token.Get(keyRole)
if !exists {
return nil, errors.New("could not find 'thumbprint' claim")
}
role, ok := rawRole.(string)
if !ok {
return nil, errors.Errorf("unexpected '%s' claim value: '%v'", keyRole, rawRole)
}
if !isValidRole(role) {
return nil, errors.Errorf("invalid role '%s'", role)
}
user := &User{
subject: token.Subject(),
role: Role(role),
}
return user, nil
}
func NewAuthenticator(keys jwk.Set, issuer string, acceptableSkew time.Duration) *Authenticator {
return &Authenticator{
keys: keys,
issuer: issuer,
acceptableSkew: acceptableSkew,
}
}
var _ auth.Authenticator = &Authenticator{}

62
internal/auth/jwt/jwt.go Normal file
View File

@ -0,0 +1,62 @@
package jwt
import (
"context"
"time"
"forge.cadoles.com/cadoles/bouncer/internal/jwk"
"github.com/lestrrat-go/jwx/v2/jwa"
"github.com/lestrrat-go/jwx/v2/jws"
"github.com/lestrrat-go/jwx/v2/jwt"
"github.com/pkg/errors"
)
const keyRole = "role"
func parseToken(ctx context.Context, keys jwk.Set, issuer string, rawToken string, acceptableSkew time.Duration) (jwt.Token, error) {
token, err := jwt.Parse(
[]byte(rawToken),
jwt.WithKeySet(keys, jws.WithRequireKid(false)),
jwt.WithIssuer(issuer),
jwt.WithValidate(true),
jwt.WithAcceptableSkew(acceptableSkew),
)
if err != nil {
return nil, errors.WithStack(err)
}
return token, nil
}
func GenerateToken(ctx context.Context, key jwk.Key, issuer, subject string, role Role) (string, error) {
token := jwt.New()
if err := token.Set(jwt.SubjectKey, subject); err != nil {
return "", errors.WithStack(err)
}
if err := token.Set(jwt.IssuerKey, issuer); err != nil {
return "", errors.WithStack(err)
}
if err := token.Set(keyRole, role); err != nil {
return "", errors.WithStack(err)
}
now := time.Now().UTC()
if err := token.Set(jwt.NotBeforeKey, now); err != nil {
return "", errors.WithStack(err)
}
if err := token.Set(jwt.IssuedAtKey, now); err != nil {
return "", errors.WithStack(err)
}
rawToken, err := jwt.Sign(token, jwt.WithKey(jwa.RS256, key))
if err != nil {
return "", errors.WithStack(err)
}
return string(rawToken), nil
}

32
internal/auth/jwt/user.go Normal file
View File

@ -0,0 +1,32 @@
package jwt
import "forge.cadoles.com/cadoles/bouncer/internal/auth"
type Role string
const (
RoleWriter Role = "writer"
RoleReader Role = "reader"
)
func isValidRole(r string) bool {
rr := Role(r)
return rr == RoleWriter || rr == RoleReader
}
type User struct {
subject string
role Role
}
// Subject implements auth.User
func (u *User) Subject() string {
return u.subject
}
func (u *User) Role() Role {
return u.role
}
var _ auth.User = &User{}

View File

@ -0,0 +1,79 @@
package auth
import (
"context"
"net/http"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/api"
"gitlab.com/wpetit/goweb/logger"
)
const (
ErrCodeUnauthorized api.ErrorCode = "unauthorized"
ErrCodeForbidden api.ErrorCode = "forbidden"
)
type contextKey string
const (
contextKeyUser contextKey = "user"
)
func CtxUser(ctx context.Context) (User, error) {
user, ok := ctx.Value(contextKeyUser).(User)
if !ok {
return nil, errors.Errorf("unexpected user type: expected '%T', got '%T'", new(User), ctx.Value(contextKeyUser))
}
return user, nil
}
var ErrUnauthenticated = errors.New("unauthenticated")
type User interface {
Subject() string
}
type Authenticator interface {
Authenticate(context.Context, *http.Request) (User, error)
}
func Middleware(authenticators ...Authenticator) func(http.Handler) http.Handler {
return func(h http.Handler) http.Handler {
fn := func(w http.ResponseWriter, r *http.Request) {
ctx := logger.With(r.Context(), logger.F("remoteAddr", r.RemoteAddr))
var (
user User
err error
)
for _, auth := range authenticators {
user, err = auth.Authenticate(ctx, r)
if err != nil {
logger.Debug(ctx, "could not authenticate request", logger.E(errors.WithStack(err)))
continue
}
if user != nil {
break
}
}
if user == nil {
api.ErrorResponse(w, http.StatusUnauthorized, ErrCodeUnauthorized, nil)
return
}
ctx = logger.With(ctx, logger.F("user", user.Subject()))
ctx = context.WithValue(ctx, contextKeyUser, user)
h.ServeHTTP(w, r.WithContext(ctx))
}
return http.HandlerFunc(fn)
}
}

View File

@ -0,0 +1,53 @@
package chi
import (
"context"
"fmt"
"net/http"
"time"
"github.com/go-chi/chi/v5/middleware"
"gitlab.com/wpetit/goweb/logger"
)
type LogFormatter struct{}
// NewLogEntry implements middleware.LogFormatter
func (*LogFormatter) NewLogEntry(r *http.Request) middleware.LogEntry {
return &LogEntry{
method: r.Method,
path: r.URL.Path,
ctx: r.Context(),
}
}
func NewLogFormatter() *LogFormatter {
return &LogFormatter{}
}
var _ middleware.LogFormatter = &LogFormatter{}
type LogEntry struct {
method string
path string
ctx context.Context
}
// Panic implements middleware.LogEntry
func (e *LogEntry) Panic(v interface{}, stack []byte) {
logger.Error(e.ctx, fmt.Sprintf("%s %s", e.method, e.path), logger.F("stack", string(stack)))
}
// Write implements middleware.LogEntry
func (e *LogEntry) Write(status int, bytes int, header http.Header, elapsed time.Duration, extra interface{}) {
logger.Info(e.ctx, fmt.Sprintf("%s %s - %d", e.method, e.path, status),
logger.F("status", status),
logger.F("bytes", bytes),
logger.F("elapsed", elapsed),
logger.F("method", e.method),
logger.F("path", e.path),
logger.F("extra", extra),
)
}
var _ middleware.LogEntry = &LogEntry{}

144
internal/client/client.go Normal file
View File

@ -0,0 +1,144 @@
package client
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/api"
"gitlab.com/wpetit/goweb/logger"
)
type Client struct {
http *http.Client
defaultOpts Options
serverURL string
}
func (c *Client) apiGet(ctx context.Context, path string, result any, funcs ...OptionFunc) error {
if err := c.apiDo(ctx, http.MethodGet, path, nil, result, funcs...); err != nil {
return errors.WithStack(err)
}
return nil
}
func (c *Client) apiPost(ctx context.Context, path string, payload any, result any, funcs ...OptionFunc) error {
if err := c.apiDo(ctx, http.MethodPost, path, payload, result, funcs...); err != nil {
return errors.WithStack(err)
}
return nil
}
func (c *Client) apiPut(ctx context.Context, path string, payload any, result any, funcs ...OptionFunc) error {
if err := c.apiDo(ctx, http.MethodPut, path, payload, result, funcs...); err != nil {
return errors.WithStack(err)
}
return nil
}
func (c *Client) apiDelete(ctx context.Context, path string, payload any, result any, funcs ...OptionFunc) error {
if err := c.apiDo(ctx, http.MethodDelete, path, payload, result, funcs...); err != nil {
return errors.WithStack(err)
}
return nil
}
func (c *Client) apiDo(ctx context.Context, method string, path string, payload any, response any, funcs ...OptionFunc) error {
opts := c.defaultOptions()
for _, fn := range funcs {
fn(opts)
}
url := c.serverURL + path
logger.Debug(
ctx, "new http request",
logger.F("method", method),
logger.F("url", url),
logger.F("payload", payload),
)
var buf bytes.Buffer
encoder := json.NewEncoder(&buf)
if err := encoder.Encode(payload); err != nil {
return errors.WithStack(err)
}
req, err := http.NewRequest(method, url, &buf)
if err != nil {
return errors.WithStack(err)
}
for key, values := range opts.Headers {
for _, v := range values {
req.Header.Add(key, v)
}
}
res, err := c.http.Do(req)
if err != nil {
return errors.WithStack(err)
}
defer res.Body.Close()
decoder := json.NewDecoder(res.Body)
if err := decoder.Decode(&response); err != nil {
return errors.WithStack(err)
}
return nil
}
func (c *Client) defaultOptions() *Options {
return &Options{
Headers: c.defaultOpts.Headers,
}
}
func withResponse[T any]() struct {
Data T
Error *api.Error
} {
return struct {
Data T
Error *api.Error
}{}
}
func joinSlice[T any](items []T) string {
str := ""
for idx, item := range items {
if idx != 0 {
str += ","
}
str += fmt.Sprintf("%v", item)
}
return str
}
func New(serverURL string, funcs ...OptionFunc) *Client {
opts := Options{}
for _, fn := range funcs {
fn(&opts)
}
return &Client{
serverURL: serverURL,
http: &http.Client{},
defaultOpts: opts,
}
}

View File

@ -0,0 +1,32 @@
package client
import (
"context"
"fmt"
"forge.cadoles.com/cadoles/bouncer/internal/admin"
"forge.cadoles.com/cadoles/bouncer/internal/store"
"github.com/pkg/errors"
)
func (c *Client) CreateLayer(ctx context.Context, proxyName store.ProxyName, layerName store.LayerName, layerType store.LayerType, options store.LayerOptions, funcs ...OptionFunc) (*store.Layer, error) {
request := admin.CreateLayerRequest{
Name: string(layerName),
Type: string(layerType),
Options: options,
}
response := withResponse[admin.CreateLayerResponse]()
path := fmt.Sprintf("/api/v1/proxies/%s/layers", proxyName)
if err := c.apiPost(ctx, path, request, &response); err != nil {
return nil, errors.WithStack(err)
}
if response.Error != nil {
return nil, errors.WithStack(response.Error)
}
return response.Data.Layer, nil
}

View File

@ -0,0 +1,30 @@
package client
import (
"context"
"net/url"
"forge.cadoles.com/cadoles/bouncer/internal/admin"
"forge.cadoles.com/cadoles/bouncer/internal/store"
"github.com/pkg/errors"
)
func (c *Client) CreateProxy(ctx context.Context, name store.ProxyName, to *url.URL, from []string, funcs ...OptionFunc) (*store.Proxy, error) {
request := admin.CreateProxyRequest{
Name: string(name),
To: to.String(),
From: from,
}
response := withResponse[admin.CreateProxyResponse]()
if err := c.apiPost(ctx, "/api/v1/proxies", request, &response); err != nil {
return nil, errors.WithStack(err)
}
if response.Error != nil {
return nil, errors.WithStack(response.Error)
}
return response.Data.Proxy, nil
}

View File

@ -0,0 +1,26 @@
package client
import (
"context"
"fmt"
"forge.cadoles.com/cadoles/bouncer/internal/admin"
"forge.cadoles.com/cadoles/bouncer/internal/store"
"github.com/pkg/errors"
)
func (c *Client) DeleteLayer(ctx context.Context, proxyName store.ProxyName, layerName store.LayerName, funcs ...OptionFunc) (store.LayerName, error) {
response := withResponse[admin.DeleteLayerResponse]()
path := fmt.Sprintf("/api/v1/proxies/%s/layers/%s", proxyName, layerName)
if err := c.apiDelete(ctx, path, nil, &response, funcs...); err != nil {
return "", errors.WithStack(err)
}
if response.Error != nil {
return "", errors.WithStack(response.Error)
}
return response.Data.LayerName, nil
}

View File

@ -0,0 +1,26 @@
package client
import (
"context"
"fmt"
"forge.cadoles.com/cadoles/bouncer/internal/admin"
"forge.cadoles.com/cadoles/bouncer/internal/store"
"github.com/pkg/errors"
)
func (c *Client) DeleteProxy(ctx context.Context, proxyName store.ProxyName, funcs ...OptionFunc) (store.ProxyName, error) {
response := withResponse[admin.DeleteProxyResponse]()
path := fmt.Sprintf("/api/v1/proxies/%s", proxyName)
if err := c.apiDelete(ctx, path, nil, &response, funcs...); err != nil {
return "", errors.WithStack(err)
}
if response.Error != nil {
return "", errors.WithStack(response.Error)
}
return response.Data.ProxyName, nil
}

View File

@ -0,0 +1,26 @@
package client
import (
"context"
"fmt"
"forge.cadoles.com/cadoles/bouncer/internal/admin"
"forge.cadoles.com/cadoles/bouncer/internal/store"
"github.com/pkg/errors"
)
func (c *Client) GetLayer(ctx context.Context, proxyName store.ProxyName, layerName store.LayerName, funcs ...OptionFunc) (*store.Layer, error) {
response := withResponse[admin.GetLayerResponse]()
path := fmt.Sprintf("/api/v1/proxies/%s/layers/%s", proxyName, layerName)
if err := c.apiGet(ctx, path, &response, funcs...); err != nil {
return nil, errors.WithStack(err)
}
if response.Error != nil {
return nil, errors.WithStack(response.Error)
}
return response.Data.Layer, nil
}

View File

@ -0,0 +1,26 @@
package client
import (
"context"
"fmt"
"forge.cadoles.com/cadoles/bouncer/internal/admin"
"forge.cadoles.com/cadoles/bouncer/internal/store"
"github.com/pkg/errors"
)
func (c *Client) GetProxy(ctx context.Context, proxyName store.ProxyName, funcs ...OptionFunc) (*store.Proxy, error) {
response := withResponse[admin.GetProxyResponse]()
path := fmt.Sprintf("/api/v1/proxies/%s", proxyName)
if err := c.apiGet(ctx, path, &response, funcs...); err != nil {
return nil, errors.WithStack(err)
}
if response.Error != nil {
return nil, errors.WithStack(response.Error)
}
return response.Data.Proxy, nil
}

View File

@ -0,0 +1,24 @@
package client
import "net/http"
type Options struct {
Headers http.Header
}
type OptionFunc func(*Options)
func WithToken(token string) OptionFunc {
return func(o *Options) {
if o.Headers == nil {
o.Headers = http.Header{}
}
o.Headers.Set("Authorization", "Bearer "+token)
}
}
func WithHeaders(headers http.Header) OptionFunc {
return func(o *Options) {
o.Headers = headers
}
}

View File

@ -0,0 +1,75 @@
package client
import (
"context"
"fmt"
"net/url"
"forge.cadoles.com/cadoles/bouncer/internal/admin"
"forge.cadoles.com/cadoles/bouncer/internal/store"
"github.com/pkg/errors"
)
type QueryLayerOptionFunc func(*QueryLayerOptions)
type QueryLayerOptions struct {
Options []OptionFunc
Offset *int
Limit *int
Names []store.LayerName
}
func WithQueryLayerOptions(funcs ...OptionFunc) QueryLayerOptionFunc {
return func(opts *QueryLayerOptions) {
opts.Options = funcs
}
}
func WithQueryLayerLimit(limit int) QueryLayerOptionFunc {
return func(opts *QueryLayerOptions) {
opts.Limit = &limit
}
}
func WithQueryLayerOffset(offset int) QueryLayerOptionFunc {
return func(opts *QueryLayerOptions) {
opts.Offset = &offset
}
}
func WithQueryLayerNames(names ...store.LayerName) QueryLayerOptionFunc {
return func(opts *QueryLayerOptions) {
opts.Names = names
}
}
func (c *Client) QueryLayer(ctx context.Context, proxyName store.ProxyName, funcs ...QueryLayerOptionFunc) ([]*store.LayerHeader, error) {
options := &QueryLayerOptions{}
for _, fn := range funcs {
fn(options)
}
query := url.Values{}
if options.Names != nil && len(options.Names) > 0 {
query.Set("names", joinSlice(options.Names))
}
path := fmt.Sprintf("/api/v1/proxies/%s/layers?%s", proxyName, query.Encode())
response := withResponse[admin.QueryLayerResponse]()
if options.Options == nil {
options.Options = make([]OptionFunc, 0)
}
if err := c.apiGet(ctx, path, &response, options.Options...); err != nil {
return nil, errors.WithStack(err)
}
if response.Error != nil {
return nil, errors.WithStack(response.Error)
}
return response.Data.Layers, nil
}

View File

@ -0,0 +1,75 @@
package client
import (
"context"
"fmt"
"net/url"
"forge.cadoles.com/cadoles/bouncer/internal/admin"
"forge.cadoles.com/cadoles/bouncer/internal/store"
"github.com/pkg/errors"
)
type QueryProxyOptionFunc func(*QueryProxyOptions)
type QueryProxyOptions struct {
Options []OptionFunc
Limit *int
Offset *int
Names []store.ProxyName
}
func WithQueryProxyOptions(funcs ...OptionFunc) QueryProxyOptionFunc {
return func(opts *QueryProxyOptions) {
opts.Options = funcs
}
}
func WithQueryProxyLimit(limit int) QueryProxyOptionFunc {
return func(opts *QueryProxyOptions) {
opts.Limit = &limit
}
}
func WithQueryProxyOffset(offset int) QueryProxyOptionFunc {
return func(opts *QueryProxyOptions) {
opts.Offset = &offset
}
}
func WithQueryProxyNames(names ...store.ProxyName) QueryProxyOptionFunc {
return func(opts *QueryProxyOptions) {
opts.Names = names
}
}
func (c *Client) QueryProxy(ctx context.Context, funcs ...QueryProxyOptionFunc) ([]*store.ProxyHeader, error) {
options := &QueryProxyOptions{}
for _, fn := range funcs {
fn(options)
}
query := url.Values{}
if options.Names != nil && len(options.Names) > 0 {
query.Set("names", joinSlice(options.Names))
}
path := fmt.Sprintf("/api/v1/proxies?%s", query.Encode())
response := withResponse[admin.QueryProxyResponse]()
if options.Options == nil {
options.Options = make([]OptionFunc, 0)
}
if err := c.apiGet(ctx, path, &response, options.Options...); err != nil {
return nil, errors.WithStack(err)
}
if response.Error != nil {
return nil, errors.WithStack(response.Error)
}
return response.Data.Proxies, nil
}

View File

@ -0,0 +1,28 @@
package client
import (
"context"
"fmt"
"forge.cadoles.com/cadoles/bouncer/internal/admin"
"forge.cadoles.com/cadoles/bouncer/internal/store"
"github.com/pkg/errors"
)
type UpdateLayerOptions = admin.UpdateLayerRequest
func (c *Client) UpdateLayer(ctx context.Context, proxyName store.ProxyName, layerName store.LayerName, opts *UpdateLayerOptions, funcs ...OptionFunc) (*store.Layer, error) {
response := withResponse[admin.UpdateLayerResponse]()
path := fmt.Sprintf("/api/v1/proxies/%s/layers/%s", proxyName, layerName)
if err := c.apiPut(ctx, path, opts, &response); err != nil {
return nil, errors.WithStack(err)
}
if response.Error != nil {
return nil, errors.WithStack(response.Error)
}
return response.Data.Layer, nil
}

View File

@ -0,0 +1,28 @@
package client
import (
"context"
"fmt"
"forge.cadoles.com/cadoles/bouncer/internal/admin"
"forge.cadoles.com/cadoles/bouncer/internal/store"
"github.com/pkg/errors"
)
type UpdateProxyOptions = admin.UpdateProxyRequest
func (c *Client) UpdateProxy(ctx context.Context, proxyName store.ProxyName, opts *UpdateProxyOptions, funcs ...OptionFunc) (*store.Proxy, error) {
response := withResponse[admin.UpdateProxyResponse]()
path := fmt.Sprintf("/api/v1/proxies/%s", proxyName)
if err := c.apiPut(ctx, path, opts, &response); err != nil {
return nil, errors.WithStack(err)
}
if response.Error != nil {
return nil, errors.WithStack(response.Error)
}
return response.Data.Proxy, nil
}

View File

@ -0,0 +1,91 @@
package apierr
import (
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/api"
)
func Wrap(err error) error {
apiErr := &api.Error{}
if !errors.As(err, &apiErr) {
return err
}
switch apiErr.Code {
case api.ErrCodeInvalidFieldValue:
return wrapInvalidFieldValueErr(apiErr)
default:
return wrapApiErrorWithMessage(apiErr)
}
}
func wrapApiErrorWithMessage(err *api.Error) error {
data, ok := err.Data.(map[string]any)
if !ok {
return err
}
rawMessage, exists := data["message"]
if !exists {
return err
}
message, ok := rawMessage.(string)
if !ok {
return err
}
return errors.Wrapf(err, message)
}
func wrapInvalidFieldValueErr(err *api.Error) error {
data, ok := err.Data.(map[string]any)
if !ok {
return err
}
rawFields, exists := data["Fields"]
if !exists {
return err
}
fields, ok := rawFields.([]any)
if !ok {
return err
}
var (
field string
rule string
)
if len(fields) == 0 {
return err
}
firstField, ok := fields[0].(map[string]any)
if !ok {
return err
}
param, ok := firstField["Param"].(string)
if !ok {
return err
}
tag, ok := firstField["Tag"].(string)
if !ok {
return err
}
fieldName, ok := firstField["Field"].(string)
if !ok {
return err
}
field = fieldName
rule = tag + "=" + param
return errors.Wrapf(err, "server expected field '%s' to match rule '%s'", field, rule)
}

View File

@ -0,0 +1,98 @@
package flag
import (
"fmt"
"io/ioutil"
"os"
"strings"
"forge.cadoles.com/cadoles/bouncer/internal/format"
"forge.cadoles.com/cadoles/bouncer/internal/format/table"
"github.com/pkg/errors"
"github.com/urfave/cli/v2"
)
func ComposeFlags(flags ...cli.Flag) []cli.Flag {
baseFlags := []cli.Flag{
&cli.StringFlag{
Name: "server",
Aliases: []string{"s"},
Usage: "use `SERVER` as server url",
Value: "http://127.0.0.1:8081",
},
&cli.StringFlag{
Name: "format",
Aliases: []string{"f"},
Usage: fmt.Sprintf("use `FORMAT` as output format (available: %s)", format.Available()),
Value: string(table.Format),
},
&cli.StringFlag{
Name: "output-mode",
Aliases: []string{"m"},
Usage: fmt.Sprintf("use `MODE` as output mode (available: %s)", []format.OutputMode{format.OutputModeCompact, format.OutputModeWide}),
Value: string(format.OutputModeCompact),
},
&cli.StringFlag{
Name: "token",
Aliases: []string{"t"},
EnvVars: []string{`BOUNCER_TOKEN`},
Usage: "use `TOKEN` as authentication token",
},
&cli.StringFlag{
Name: "token-file",
EnvVars: []string{`BOUNCER_TOKEN_FILE`},
Usage: "use `TOKEN_FILE` as file containing the authentication token",
Value: ".bouncer-token",
TakesFile: true,
},
}
flags = append(flags, baseFlags...)
return flags
}
type BaseFlags struct {
ServerURL string
Format format.Format
OutputMode format.OutputMode
Token string
TokenFile string
}
func GetBaseFlags(ctx *cli.Context) *BaseFlags {
serverURL := ctx.String("server")
rawFormat := ctx.String("format")
rawOutputMode := ctx.String("output-mode")
tokenFile := ctx.String("token-file")
token := ctx.String("token")
return &BaseFlags{
ServerURL: serverURL,
Format: format.Format(rawFormat),
OutputMode: format.OutputMode(rawOutputMode),
Token: token,
TokenFile: tokenFile,
}
}
func GetToken(flags *BaseFlags) (string, error) {
if flags.Token != "" {
return flags.Token, nil
}
if flags.TokenFile == "" {
return "", nil
}
rawToken, err := ioutil.ReadFile(flags.TokenFile)
if err != nil && !errors.Is(err, os.ErrNotExist) {
return "", errors.WithStack(err)
}
if rawToken == nil {
return "", nil
}
return strings.TrimSpace(string(rawToken)), nil
}

View File

@ -0,0 +1,11 @@
package flag
func AsAnySlice[T any](src []T) []any {
dst := make([]any, len(src))
for i, s := range src {
dst[i] = s
}
return dst
}

View File

@ -0,0 +1,72 @@
package layer
import (
"os"
"forge.cadoles.com/cadoles/bouncer/internal/client"
"forge.cadoles.com/cadoles/bouncer/internal/command/admin/apierr"
clientFlag "forge.cadoles.com/cadoles/bouncer/internal/command/admin/flag"
"forge.cadoles.com/cadoles/bouncer/internal/command/admin/layer/flag"
layerFlag "forge.cadoles.com/cadoles/bouncer/internal/command/admin/layer/flag"
proxyFlag "forge.cadoles.com/cadoles/bouncer/internal/command/admin/proxy/flag"
"forge.cadoles.com/cadoles/bouncer/internal/format"
"github.com/pkg/errors"
"github.com/urfave/cli/v2"
)
func CreateCommand() *cli.Command {
return &cli.Command{
Name: "create",
Usage: "Create layer",
Flags: layerFlag.WithLayerCreateFlags(),
Action: func(ctx *cli.Context) error {
baseFlags := clientFlag.GetBaseFlags(ctx)
token, err := clientFlag.GetToken(baseFlags)
if err != nil {
return errors.WithStack(apierr.Wrap(err))
}
layerName, err := flag.AssertLayerName(ctx)
if err != nil {
return errors.WithStack(err)
}
proxyName, err := proxyFlag.AssertProxyName(ctx)
if err != nil {
return errors.WithStack(err)
}
layerType, err := flag.AssertLayerType(ctx)
if err != nil {
return errors.WithStack(err)
}
layerOptions, err := flag.AssertLayerOptions(ctx)
if err != nil {
return errors.WithStack(err)
}
client := client.New(baseFlags.ServerURL, client.WithToken(token))
layer, err := client.CreateLayer(
ctx.Context,
proxyName,
layerName,
layerType,
layerOptions,
)
if err != nil {
return errors.WithStack(apierr.Wrap(err))
}
hints := layerHints(baseFlags.OutputMode)
if err := format.Write(baseFlags.Format, os.Stdout, hints, layer); err != nil {
return errors.WithStack(err)
}
return nil
},
}
}

View File

@ -0,0 +1,62 @@
package layer
import (
"os"
"forge.cadoles.com/cadoles/bouncer/internal/client"
"forge.cadoles.com/cadoles/bouncer/internal/command/admin/apierr"
clientFlag "forge.cadoles.com/cadoles/bouncer/internal/command/admin/flag"
layerFlag "forge.cadoles.com/cadoles/bouncer/internal/command/admin/layer/flag"
proxyFlag "forge.cadoles.com/cadoles/bouncer/internal/command/admin/proxy/flag"
"forge.cadoles.com/cadoles/bouncer/internal/format"
"forge.cadoles.com/cadoles/bouncer/internal/store"
"github.com/pkg/errors"
"github.com/urfave/cli/v2"
)
func DeleteCommand() *cli.Command {
return &cli.Command{
Name: "delete",
Usage: "Delete layer",
Flags: layerFlag.WithLayerFlags(),
Action: func(ctx *cli.Context) error {
baseFlags := clientFlag.GetBaseFlags(ctx)
token, err := clientFlag.GetToken(baseFlags)
if err != nil {
return errors.WithStack(apierr.Wrap(err))
}
proxyName, err := proxyFlag.AssertProxyName(ctx)
if err != nil {
return errors.WithStack(err)
}
layerName, err := layerFlag.AssertLayerName(ctx)
if err != nil {
return errors.WithStack(err)
}
client := client.New(baseFlags.ServerURL, client.WithToken(token))
layerName, err = client.DeleteLayer(ctx.Context, proxyName, layerName)
if err != nil {
return errors.WithStack(apierr.Wrap(err))
}
hints := format.Hints{
OutputMode: baseFlags.OutputMode,
}
if err := format.Write(baseFlags.Format, os.Stdout, hints, struct {
Name store.LayerName `json:"id"`
}{
Name: layerName,
}); err != nil {
return errors.WithStack(err)
}
return nil
},
}
}

View File

@ -0,0 +1,76 @@
package flag
import (
"encoding/json"
proxyFlag "forge.cadoles.com/cadoles/bouncer/internal/command/admin/proxy/flag"
"forge.cadoles.com/cadoles/bouncer/internal/store"
"github.com/pkg/errors"
"github.com/urfave/cli/v2"
)
const (
FlagLayerName = "layer-name"
FlagLayerType = "layer-type"
FlagLayerOptions = "layer-options"
)
func WithLayerFlags(flags ...cli.Flag) []cli.Flag {
baseFlags := proxyFlag.WithProxyFlags(
&cli.StringFlag{
Name: FlagLayerName,
Usage: "use `LAYER_NAME` as targeted layer",
Value: "",
Required: true,
},
)
flags = append(flags, baseFlags...)
return flags
}
func WithLayerCreateFlags(flags ...cli.Flag) []cli.Flag {
return WithLayerFlags(
&cli.StringFlag{
Name: FlagLayerType,
Usage: "Set `LAYER_TYPE` as layer's type",
Value: "",
Required: true,
},
&cli.StringFlag{
Name: FlagLayerOptions,
Usage: "Set `LAYER_OPTIONS` as layer's options",
Value: "{}",
},
)
}
func AssertLayerName(ctx *cli.Context) (store.LayerName, error) {
rawLayerName := ctx.String(FlagLayerName)
name, err := store.ValidateName(rawLayerName)
if err != nil {
return "", errors.WithStack(err)
}
return store.LayerName(name), nil
}
func AssertLayerType(ctx *cli.Context) (store.LayerType, error) {
rawLayerType := ctx.String(FlagLayerType)
return store.LayerType(rawLayerType), nil
}
func AssertLayerOptions(ctx *cli.Context) (store.LayerOptions, error) {
rawLayerOptions := ctx.String(FlagLayerOptions)
layerOptions := store.LayerOptions{}
if err := json.Unmarshal([]byte(rawLayerOptions), &layerOptions); err != nil {
return nil, errors.WithStack(err)
}
return layerOptions, nil
}

View File

@ -0,0 +1,55 @@
package layer
import (
"os"
"forge.cadoles.com/cadoles/bouncer/internal/client"
"forge.cadoles.com/cadoles/bouncer/internal/command/admin/apierr"
clientFlag "forge.cadoles.com/cadoles/bouncer/internal/command/admin/flag"
layerFlag "forge.cadoles.com/cadoles/bouncer/internal/command/admin/layer/flag"
proxyFlag "forge.cadoles.com/cadoles/bouncer/internal/command/admin/proxy/flag"
"forge.cadoles.com/cadoles/bouncer/internal/format"
"github.com/pkg/errors"
"github.com/urfave/cli/v2"
)
func GetCommand() *cli.Command {
return &cli.Command{
Name: "get",
Usage: "Get layer",
Flags: layerFlag.WithLayerFlags(),
Action: func(ctx *cli.Context) error {
baseFlags := clientFlag.GetBaseFlags(ctx)
token, err := clientFlag.GetToken(baseFlags)
if err != nil {
return errors.WithStack(apierr.Wrap(err))
}
proxyName, err := proxyFlag.AssertProxyName(ctx)
if err != nil {
return errors.WithStack(err)
}
layerName, err := layerFlag.AssertLayerName(ctx)
if err != nil {
return errors.WithStack(err)
}
client := client.New(baseFlags.ServerURL, client.WithToken(token))
layer, err := client.GetLayer(ctx.Context, proxyName, layerName)
if err != nil {
return errors.WithStack(apierr.Wrap(err))
}
hints := layerHints(baseFlags.OutputMode)
if err := format.Write(baseFlags.Format, os.Stdout, hints, layer); err != nil {
return errors.WithStack(err)
}
return nil
},
}
}

View File

@ -0,0 +1,69 @@
package layer
import (
"os"
"forge.cadoles.com/cadoles/bouncer/internal/client"
"forge.cadoles.com/cadoles/bouncer/internal/command/admin/apierr"
clientFlag "forge.cadoles.com/cadoles/bouncer/internal/command/admin/flag"
proxyFlag "forge.cadoles.com/cadoles/bouncer/internal/command/admin/proxy/flag"
"forge.cadoles.com/cadoles/bouncer/internal/format"
"forge.cadoles.com/cadoles/bouncer/internal/store"
"github.com/pkg/errors"
"github.com/urfave/cli/v2"
)
func QueryCommand() *cli.Command {
return &cli.Command{
Name: "query",
Usage: "Query layers",
Flags: proxyFlag.WithProxyFlags(
&cli.StringSliceFlag{
Name: "with-name",
Usage: "use `WITH_NAME` as query filter",
},
),
Action: func(ctx *cli.Context) error {
baseFlags := clientFlag.GetBaseFlags(ctx)
token, err := clientFlag.GetToken(baseFlags)
if err != nil {
return errors.WithStack(apierr.Wrap(err))
}
proxyName, err := proxyFlag.AssertProxyName(ctx)
if err != nil {
return errors.WithStack(err)
}
options := make([]client.QueryLayerOptionFunc, 0)
rawNames := ctx.StringSlice("with-name")
if rawNames != nil {
layerNames := func(names []string) []store.LayerName {
layerNames := make([]store.LayerName, len(names))
for i, name := range names {
layerNames[i] = store.LayerName(name)
}
return layerNames
}(rawNames)
options = append(options, client.WithQueryLayerNames(layerNames...))
}
client := client.New(baseFlags.ServerURL, client.WithToken(token))
proxies, err := client.QueryLayer(ctx.Context, proxyName, options...)
if err != nil {
return errors.WithStack(apierr.Wrap(err))
}
hints := layerHeaderHints(baseFlags.OutputMode)
if err := format.Write(baseFlags.Format, os.Stdout, hints, clientFlag.AsAnySlice(proxies)...); err != nil {
return errors.WithStack(err)
}
return nil
},
}
}

View File

@ -0,0 +1,19 @@
package layer
import (
"github.com/urfave/cli/v2"
)
func Root() *cli.Command {
return &cli.Command{
Name: "layer",
Usage: "Execute actions related to layers",
Subcommands: []*cli.Command{
CreateCommand(),
GetCommand(),
QueryCommand(),
UpdateCommand(),
DeleteCommand(),
},
}
}

View File

@ -0,0 +1,91 @@
package layer
import (
"encoding/json"
"os"
"forge.cadoles.com/cadoles/bouncer/internal/client"
"forge.cadoles.com/cadoles/bouncer/internal/command/admin/apierr"
clientFlag "forge.cadoles.com/cadoles/bouncer/internal/command/admin/flag"
layerFlag "forge.cadoles.com/cadoles/bouncer/internal/command/admin/layer/flag"
proxyFlag "forge.cadoles.com/cadoles/bouncer/internal/command/admin/proxy/flag"
"forge.cadoles.com/cadoles/bouncer/internal/format"
"forge.cadoles.com/cadoles/bouncer/internal/store"
"github.com/pkg/errors"
"github.com/urfave/cli/v2"
)
func UpdateCommand() *cli.Command {
return &cli.Command{
Name: "update",
Usage: "Update layer",
Flags: layerFlag.WithLayerFlags(
&cli.BoolFlag{
Name: "enabled",
Usage: "Enable or disable proxy",
},
&cli.IntFlag{
Name: "weight",
Usage: "Set `WEIGHT` as proxy's weight",
},
&cli.StringFlag{
Name: "options",
Usage: "Set `OPTIONS` as proxy's options",
},
),
Action: func(ctx *cli.Context) error {
baseFlags := clientFlag.GetBaseFlags(ctx)
token, err := clientFlag.GetToken(baseFlags)
if err != nil {
return errors.WithStack(apierr.Wrap(err))
}
proxyName, err := proxyFlag.AssertProxyName(ctx)
if err != nil {
return errors.WithStack(err)
}
layerName, err := layerFlag.AssertLayerName(ctx)
if err != nil {
return errors.WithStack(err)
}
opts := &client.UpdateLayerOptions{}
if ctx.IsSet("options") {
var options store.LayerOptions
if err := json.Unmarshal([]byte(ctx.String("options")), &options); err != nil {
return errors.Wrap(err, "could not parse options")
}
opts.Options = &options
}
if ctx.IsSet("weight") {
weight := ctx.Int("weight")
opts.Weight = &weight
}
if ctx.IsSet("enabled") {
enabled := ctx.Bool("enabled")
opts.Enabled = &enabled
}
client := client.New(baseFlags.ServerURL, client.WithToken(token))
proxy, err := client.UpdateLayer(ctx.Context, proxyName, layerName, opts)
if err != nil {
return errors.WithStack(apierr.Wrap(err))
}
hints := layerHints(baseFlags.OutputMode)
if err := format.Write(baseFlags.Format, os.Stdout, hints, proxy); err != nil {
return errors.WithStack(err)
}
return nil
},
}
}

View File

@ -0,0 +1,33 @@
package layer
import (
"forge.cadoles.com/cadoles/bouncer/internal/format"
"forge.cadoles.com/cadoles/bouncer/internal/format/table"
)
func layerHeaderHints(outputMode format.OutputMode) format.Hints {
return format.Hints{
OutputMode: outputMode,
Props: []format.Prop{
format.NewProp("Name", "Name"),
format.NewProp("Type", "Type"),
format.NewProp("Enabled", "Enabled"),
format.NewProp("Weight", "Weight"),
},
}
}
func layerHints(outputMode format.OutputMode) format.Hints {
return format.Hints{
OutputMode: outputMode,
Props: []format.Prop{
format.NewProp("Name", "Name"),
format.NewProp("Type", "Type"),
format.NewProp("Enabled", "Enabled"),
format.NewProp("Weight", "Weight"),
format.NewProp("Options", "Options"),
format.NewProp("CreatedAt", "CreatedAt", table.WithCompactModeMaxColumnWidth(20)),
format.NewProp("UpdatedAt", "UpdatedAt", table.WithCompactModeMaxColumnWidth(20)),
},
}
}

View File

@ -0,0 +1,69 @@
package proxy
import (
"net/url"
"os"
"forge.cadoles.com/cadoles/bouncer/internal/client"
"forge.cadoles.com/cadoles/bouncer/internal/command/admin/apierr"
clientFlag "forge.cadoles.com/cadoles/bouncer/internal/command/admin/flag"
proxyFlag "forge.cadoles.com/cadoles/bouncer/internal/command/admin/proxy/flag"
"forge.cadoles.com/cadoles/bouncer/internal/format"
"github.com/pkg/errors"
"github.com/urfave/cli/v2"
)
func CreateCommand() *cli.Command {
return &cli.Command{
Name: "create",
Usage: "Create proxy",
Flags: proxyFlag.WithProxyFlags(
&cli.StringFlag{
Name: "to",
Usage: "Set `TO` as proxy's destination url",
Value: "",
Required: true,
},
&cli.StringSliceFlag{
Name: "from",
Usage: "Set `FROM` as proxy's patterns to match incoming requests",
Value: cli.NewStringSlice("*"),
},
),
Action: func(ctx *cli.Context) error {
baseFlags := clientFlag.GetBaseFlags(ctx)
token, err := clientFlag.GetToken(baseFlags)
if err != nil {
return errors.WithStack(apierr.Wrap(err))
}
proxyName, err := proxyFlag.AssertProxyName(ctx)
if err != nil {
return errors.Wrap(err, "'to' parameter should be a valid url")
}
to, err := url.Parse(ctx.String("to"))
if err != nil {
return errors.Wrap(err, "'to' parameter should be a valid url")
}
from := ctx.StringSlice("from")
client := client.New(baseFlags.ServerURL, client.WithToken(token))
proxy, err := client.CreateProxy(ctx.Context, proxyName, to, from)
if err != nil {
return errors.WithStack(apierr.Wrap(err))
}
hints := proxyHints(baseFlags.OutputMode)
if err := format.Write(baseFlags.Format, os.Stdout, hints, proxy); err != nil {
return errors.WithStack(err)
}
return nil
},
}
}

View File

@ -0,0 +1,56 @@
package proxy
import (
"os"
"forge.cadoles.com/cadoles/bouncer/internal/client"
"forge.cadoles.com/cadoles/bouncer/internal/command/admin/apierr"
clientFlag "forge.cadoles.com/cadoles/bouncer/internal/command/admin/flag"
proxyFlag "forge.cadoles.com/cadoles/bouncer/internal/command/admin/proxy/flag"
"forge.cadoles.com/cadoles/bouncer/internal/format"
"forge.cadoles.com/cadoles/bouncer/internal/store"
"github.com/pkg/errors"
"github.com/urfave/cli/v2"
)
func DeleteCommand() *cli.Command {
return &cli.Command{
Name: "delete",
Usage: "Delete proxy",
Flags: proxyFlag.WithProxyFlags(),
Action: func(ctx *cli.Context) error {
baseFlags := clientFlag.GetBaseFlags(ctx)
token, err := clientFlag.GetToken(baseFlags)
if err != nil {
return errors.WithStack(apierr.Wrap(err))
}
proxyName, err := proxyFlag.AssertProxyName(ctx)
if err != nil {
return errors.WithStack(err)
}
client := client.New(baseFlags.ServerURL, client.WithToken(token))
proxyName, err = client.DeleteProxy(ctx.Context, proxyName)
if err != nil {
return errors.WithStack(apierr.Wrap(err))
}
hints := format.Hints{
OutputMode: baseFlags.OutputMode,
}
if err := format.Write(baseFlags.Format, os.Stdout, hints, struct {
Name store.ProxyName `json:"id"`
}{
Name: proxyName,
}); err != nil {
return errors.WithStack(err)
}
return nil
},
}
}

View File

@ -0,0 +1,36 @@
package flag
import (
clientFlag "forge.cadoles.com/cadoles/bouncer/internal/command/admin/flag"
"forge.cadoles.com/cadoles/bouncer/internal/store"
"github.com/pkg/errors"
"github.com/urfave/cli/v2"
)
const FlagProxyName = "proxy-name"
func WithProxyFlags(flags ...cli.Flag) []cli.Flag {
baseFlags := clientFlag.ComposeFlags(
&cli.StringFlag{
Name: FlagProxyName,
Usage: "use `PROXY_NAME` as targeted proxy",
Value: "",
Required: true,
},
)
flags = append(flags, baseFlags...)
return flags
}
func AssertProxyName(ctx *cli.Context) (store.ProxyName, error) {
rawProxyName := ctx.String(FlagProxyName)
name, err := store.ValidateName(rawProxyName)
if err != nil {
return "", errors.WithStack(err)
}
return store.ProxyName(name), nil
}

View File

@ -0,0 +1,49 @@
package proxy
import (
"os"
"forge.cadoles.com/cadoles/bouncer/internal/client"
"forge.cadoles.com/cadoles/bouncer/internal/command/admin/apierr"
clientFlag "forge.cadoles.com/cadoles/bouncer/internal/command/admin/flag"
proxyFlag "forge.cadoles.com/cadoles/bouncer/internal/command/admin/proxy/flag"
"forge.cadoles.com/cadoles/bouncer/internal/format"
"github.com/pkg/errors"
"github.com/urfave/cli/v2"
)
func GetCommand() *cli.Command {
return &cli.Command{
Name: "get",
Usage: "Get proxy",
Flags: proxyFlag.WithProxyFlags(),
Action: func(ctx *cli.Context) error {
baseFlags := clientFlag.GetBaseFlags(ctx)
token, err := clientFlag.GetToken(baseFlags)
if err != nil {
return errors.WithStack(apierr.Wrap(err))
}
proxyName, err := proxyFlag.AssertProxyName(ctx)
if err != nil {
return errors.WithStack(err)
}
client := client.New(baseFlags.ServerURL, client.WithToken(token))
proxy, err := client.GetProxy(ctx.Context, proxyName)
if err != nil {
return errors.WithStack(apierr.Wrap(err))
}
hints := proxyHints(baseFlags.OutputMode)
if err := format.Write(baseFlags.Format, os.Stdout, hints, proxy); err != nil {
return errors.WithStack(err)
}
return nil
},
}
}

View File

@ -0,0 +1,63 @@
package proxy
import (
"os"
"forge.cadoles.com/cadoles/bouncer/internal/client"
"forge.cadoles.com/cadoles/bouncer/internal/command/admin/apierr"
clientFlag "forge.cadoles.com/cadoles/bouncer/internal/command/admin/flag"
"forge.cadoles.com/cadoles/bouncer/internal/format"
"forge.cadoles.com/cadoles/bouncer/internal/store"
"github.com/pkg/errors"
"github.com/urfave/cli/v2"
)
func QueryCommand() *cli.Command {
return &cli.Command{
Name: "query",
Usage: "Query proxies",
Flags: clientFlag.ComposeFlags(
&cli.Int64SliceFlag{
Name: "ids",
Usage: "use `IDS` as query filter",
},
),
Action: func(ctx *cli.Context) error {
baseFlags := clientFlag.GetBaseFlags(ctx)
token, err := clientFlag.GetToken(baseFlags)
if err != nil {
return errors.WithStack(apierr.Wrap(err))
}
options := make([]client.QueryProxyOptionFunc, 0)
rawNames := ctx.StringSlice("ids")
if rawNames != nil {
proxyNames := func(names []string) []store.ProxyName {
proxyNames := make([]store.ProxyName, len(names))
for i, name := range names {
proxyNames[i] = store.ProxyName(name)
}
return proxyNames
}(rawNames)
options = append(options, client.WithQueryProxyNames(proxyNames...))
}
client := client.New(baseFlags.ServerURL, client.WithToken(token))
proxies, err := client.QueryProxy(ctx.Context, options...)
if err != nil {
return errors.WithStack(apierr.Wrap(err))
}
hints := proxyHeaderHints(baseFlags.OutputMode)
if err := format.Write(baseFlags.Format, os.Stdout, hints, clientFlag.AsAnySlice(proxies)...); err != nil {
return errors.WithStack(err)
}
return nil
},
}
}

View File

@ -0,0 +1,19 @@
package proxy
import (
"github.com/urfave/cli/v2"
)
func Root() *cli.Command {
return &cli.Command{
Name: "proxy",
Usage: "Execute actions related to proxies",
Subcommands: []*cli.Command{
GetCommand(),
CreateCommand(),
QueryCommand(),
DeleteCommand(),
UpdateCommand(),
},
}
}

View File

@ -0,0 +1,93 @@
package proxy
import (
"net/url"
"os"
"forge.cadoles.com/cadoles/bouncer/internal/client"
"forge.cadoles.com/cadoles/bouncer/internal/command/admin/apierr"
clientFlag "forge.cadoles.com/cadoles/bouncer/internal/command/admin/flag"
proxyFlag "forge.cadoles.com/cadoles/bouncer/internal/command/admin/proxy/flag"
"forge.cadoles.com/cadoles/bouncer/internal/format"
"github.com/pkg/errors"
"github.com/urfave/cli/v2"
)
func UpdateCommand() *cli.Command {
return &cli.Command{
Name: "update",
Usage: "Update proxy",
Flags: proxyFlag.WithProxyFlags(
&cli.StringFlag{
Name: "to",
Usage: "Set `TO` as proxy's destination url",
},
&cli.StringSliceFlag{
Name: "from",
Usage: "Set `FROM` as proxy's patterns to match incoming requests",
},
&cli.BoolFlag{
Name: "enabled",
Usage: "Enable or disable proxy",
},
&cli.IntFlag{
Name: "weight",
Usage: "Set `WEIGHT` as proxy's weight",
},
),
Action: func(ctx *cli.Context) error {
baseFlags := clientFlag.GetBaseFlags(ctx)
token, err := clientFlag.GetToken(baseFlags)
if err != nil {
return errors.WithStack(apierr.Wrap(err))
}
proxyName, err := proxyFlag.AssertProxyName(ctx)
if err != nil {
return errors.WithStack(err)
}
opts := &client.UpdateProxyOptions{}
if ctx.IsSet("to") {
to := ctx.String("to")
if _, err := url.Parse(to); err != nil {
return errors.Wrap(err, "'to' parameter should be a valid url")
}
opts.To = &to
}
from := ctx.StringSlice("from")
if from != nil {
opts.From = from
}
if ctx.IsSet("weight") {
weight := ctx.Int("weight")
opts.Weight = &weight
}
if ctx.IsSet("enabled") {
enabled := ctx.Bool("enabled")
opts.Enabled = &enabled
}
client := client.New(baseFlags.ServerURL, client.WithToken(token))
proxy, err := client.UpdateProxy(ctx.Context, proxyName, opts)
if err != nil {
return errors.WithStack(apierr.Wrap(err))
}
hints := proxyHints(baseFlags.OutputMode)
if err := format.Write(baseFlags.Format, os.Stdout, hints, proxy); err != nil {
return errors.WithStack(err)
}
return nil
},
}
}

View File

@ -0,0 +1,32 @@
package proxy
import (
"forge.cadoles.com/cadoles/bouncer/internal/format"
"forge.cadoles.com/cadoles/bouncer/internal/format/table"
)
func proxyHeaderHints(outputMode format.OutputMode) format.Hints {
return format.Hints{
OutputMode: outputMode,
Props: []format.Prop{
format.NewProp("Name", "Name"),
format.NewProp("Enabled", "Enabled"),
format.NewProp("Weight", "Weight"),
},
}
}
func proxyHints(outputMode format.OutputMode) format.Hints {
return format.Hints{
OutputMode: outputMode,
Props: []format.Prop{
format.NewProp("Name", "Name"),
format.NewProp("From", "From"),
format.NewProp("To", "To"),
format.NewProp("Enabled", "Enabled"),
format.NewProp("Weight", "Weight"),
format.NewProp("CreatedAt", "CreatedAt", table.WithCompactModeMaxColumnWidth(20)),
format.NewProp("UpdatedAt", "UpdatedAt", table.WithCompactModeMaxColumnWidth(20)),
},
}
}

View File

@ -0,0 +1,18 @@
package admin
import (
"forge.cadoles.com/cadoles/bouncer/internal/command/admin/layer"
"forge.cadoles.com/cadoles/bouncer/internal/command/admin/proxy"
"github.com/urfave/cli/v2"
)
func Root() *cli.Command {
return &cli.Command{
Name: "admin",
Usage: "Admin related commands",
Subcommands: []*cli.Command{
proxy.Root(),
layer.Root(),
},
}
}

View File

@ -0,0 +1,54 @@
package auth
import (
"fmt"
"forge.cadoles.com/cadoles/bouncer/internal/auth/jwt"
"forge.cadoles.com/cadoles/bouncer/internal/command/common"
"forge.cadoles.com/cadoles/bouncer/internal/jwk"
"github.com/lithammer/shortuuid/v4"
"github.com/pkg/errors"
"github.com/urfave/cli/v2"
)
func CreateTokenCommand() *cli.Command {
return &cli.Command{
Name: "create-token",
Usage: "Create a new authentication token",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "role",
Usage: fmt.Sprintf("associate `ROLE` to the token (available: %v)", []jwt.Role{jwt.RoleReader, jwt.RoleWriter}),
Value: string(jwt.RoleReader),
},
&cli.StringFlag{
Name: "subject",
Usage: "associate `SUBJECT` to the token",
Value: fmt.Sprintf("user-%s", shortuuid.New()),
},
},
Action: func(ctx *cli.Context) error {
conf, err := common.LoadConfig(ctx)
if err != nil {
return errors.Wrap(err, "Could not load configuration")
}
subject := ctx.String("subject")
role := ctx.String("role")
key, err := jwk.LoadOrGenerate(string(conf.Admin.Auth.PrivateKey), jwk.DefaultKeySize)
if err != nil {
return errors.WithStack(err)
}
token, err := jwt.GenerateToken(ctx.Context, key, string(conf.Admin.Auth.Issuer), subject, jwt.Role(role))
if err != nil {
return errors.WithStack(err)
}
fmt.Println(token)
return nil
},
}
}

View File

@ -0,0 +1,15 @@
package auth
import (
"github.com/urfave/cli/v2"
)
func Root() *cli.Command {
return &cli.Command{
Name: "auth",
Usage: "Authentication related commands",
Subcommands: []*cli.Command{
CreateTokenCommand(),
},
}
}

View File

@ -0,0 +1,7 @@
package common
import "github.com/urfave/cli/v2"
func Flags() []cli.Flag {
return []cli.Flag{}
}

View File

@ -0,0 +1,27 @@
package common
import (
"forge.cadoles.com/cadoles/bouncer/internal/config"
"github.com/pkg/errors"
"github.com/urfave/cli/v2"
)
func LoadConfig(ctx *cli.Context) (*config.Config, error) {
configFile := ctx.String("config")
var (
conf *config.Config
err error
)
if configFile != "" {
conf, err = config.NewFromFile(configFile)
if err != nil {
return nil, errors.Wrapf(err, "Could not load config file '%s'", configFile)
}
} else {
conf = config.NewDefault()
}
return conf, nil
}

View File

@ -0,0 +1,36 @@
package config
import (
"os"
"forge.cadoles.com/cadoles/bouncer/internal/command/common"
"forge.cadoles.com/cadoles/bouncer/internal/config"
"github.com/pkg/errors"
"github.com/urfave/cli/v2"
"gitlab.com/wpetit/goweb/logger"
)
func Dump() *cli.Command {
flags := common.Flags()
return &cli.Command{
Name: "dump",
Usage: "Dump the current configuration",
Flags: flags,
Action: func(ctx *cli.Context) error {
conf, err := common.LoadConfig(ctx)
if err != nil {
return errors.Wrap(err, "Could not load configuration")
}
logger.SetFormat(logger.Format(conf.Logger.Format))
logger.SetLevel(logger.Level(conf.Logger.Level))
if err := config.Dump(conf, os.Stdout); err != nil {
return errors.Wrap(err, "Could not dump configuration")
}
return nil
},
}
}

View File

@ -0,0 +1,13 @@
package config
import "github.com/urfave/cli/v2"
func Root() *cli.Command {
return &cli.Command{
Name: "config",
Usage: "Config related commands",
Subcommands: []*cli.Command{
Dump(),
},
}
}

107
internal/command/main.go Normal file
View File

@ -0,0 +1,107 @@
package command
import (
"context"
"fmt"
"os"
"sort"
"time"
"github.com/pkg/errors"
"github.com/urfave/cli/v2"
)
func Main(buildDate, projectVersion, gitRef, defaultConfigPath string, commands ...*cli.Command) {
ctx := context.Background()
compiled, err := time.Parse(time.RFC3339, buildDate)
if err != nil {
panic(errors.Wrapf(err, "could not parse build date '%s'", buildDate))
}
app := &cli.App{
Version: fmt.Sprintf("%s (%s, %s)", projectVersion, gitRef, buildDate),
Compiled: compiled,
Name: "bouncer",
Usage: "reverse proxy server with dynamic queuing management",
Commands: commands,
Before: func(ctx *cli.Context) error {
workdir := ctx.String("workdir")
// Switch to new working directory if defined
if workdir != "" {
if err := os.Chdir(workdir); err != nil {
return errors.Wrap(err, "could not change working directory")
}
}
if err := ctx.Set("projectVersion", projectVersion); err != nil {
return errors.WithStack(err)
}
if err := ctx.Set("gitRef", gitRef); err != nil {
return errors.WithStack(err)
}
if err := ctx.Set("buildDate", buildDate); err != nil {
return errors.WithStack(err)
}
return nil
},
Flags: []cli.Flag{
&cli.StringFlag{
Name: "workdir",
Value: "",
Usage: "The working directory",
},
&cli.StringFlag{
Name: "projectVersion",
Value: "",
Hidden: true,
},
&cli.StringFlag{
Name: "gitRef",
Value: "",
Hidden: true,
},
&cli.StringFlag{
Name: "buildDate",
Value: "",
Hidden: true,
},
&cli.BoolFlag{
Name: "debug",
EnvVars: []string{"BOUNCER_DEBUG"},
Value: false,
},
&cli.StringFlag{
Name: "config",
Aliases: []string{"c"},
EnvVars: []string{"BOUNCER_CONFIG"},
Value: defaultConfigPath,
TakesFile: true,
},
},
}
app.ExitErrHandler = func(ctx *cli.Context, err error) {
if err == nil {
return
}
debug := ctx.Bool("debug")
if !debug {
fmt.Printf("[ERROR] %v\n", err)
} else {
fmt.Printf("%+v", err)
}
}
sort.Sort(cli.FlagsByName(app.Flags))
sort.Sort(cli.CommandsByName(app.Commands))
if err := app.RunContext(ctx, os.Args); err != nil {
os.Exit(1)
}
}

View File

@ -0,0 +1,15 @@
package admin
import (
"github.com/urfave/cli/v2"
)
func Root() *cli.Command {
return &cli.Command{
Name: "admin",
Usage: "Admin server related commands",
Subcommands: []*cli.Command{
RunCommand(),
},
}
}

View File

@ -0,0 +1,54 @@
package admin
import (
"fmt"
"strings"
"forge.cadoles.com/cadoles/bouncer/internal/admin"
"forge.cadoles.com/cadoles/bouncer/internal/command/common"
"github.com/pkg/errors"
"github.com/urfave/cli/v2"
"gitlab.com/wpetit/goweb/logger"
)
func RunCommand() *cli.Command {
flags := common.Flags()
return &cli.Command{
Name: "run",
Usage: "Run the admin server",
Flags: flags,
Action: func(ctx *cli.Context) error {
conf, err := common.LoadConfig(ctx)
if err != nil {
return errors.Wrap(err, "could not load configuration")
}
logger.SetFormat(logger.Format(conf.Logger.Format))
logger.SetLevel(logger.Level(conf.Logger.Level))
srv := admin.NewServer(
admin.WithServerConfig(conf.Admin),
admin.WithRedisConfig(conf.Redis),
)
addrs, srvErrs := srv.Start(ctx.Context)
select {
case addr := <-addrs:
url := fmt.Sprintf("http://%s", addr.String())
url = strings.Replace(url, "0.0.0.0", "127.0.0.1", 1)
logger.Info(ctx.Context, "listening", logger.F("url", url))
case err = <-srvErrs:
return errors.WithStack(err)
}
if err = <-srvErrs; err != nil {
return errors.WithStack(err)
}
return nil
},
}
}

View File

@ -0,0 +1,15 @@
package proxy
import (
"github.com/urfave/cli/v2"
)
func Root() *cli.Command {
return &cli.Command{
Name: "proxy",
Usage: "Proxy server related commands",
Subcommands: []*cli.Command{
RunCommand(),
},
}
}

View File

@ -0,0 +1,87 @@
package proxy
import (
"context"
"fmt"
"strings"
"forge.cadoles.com/cadoles/bouncer/internal/command/common"
"forge.cadoles.com/cadoles/bouncer/internal/config"
"forge.cadoles.com/cadoles/bouncer/internal/proxy"
"forge.cadoles.com/cadoles/bouncer/internal/proxy/director"
"forge.cadoles.com/cadoles/bouncer/internal/queue"
"forge.cadoles.com/cadoles/bouncer/internal/setup"
"github.com/pkg/errors"
"github.com/urfave/cli/v2"
"gitlab.com/wpetit/goweb/logger"
)
func RunCommand() *cli.Command {
flags := common.Flags()
return &cli.Command{
Name: "run",
Usage: "Run the proxy server",
Flags: flags,
Action: func(ctx *cli.Context) error {
conf, err := common.LoadConfig(ctx)
if err != nil {
return errors.Wrap(err, "could not load configuration")
}
logger.SetFormat(logger.Format(conf.Logger.Format))
logger.SetLevel(logger.Level(conf.Logger.Level))
layers, err := initDirectorLayers(ctx.Context, conf)
if err != nil {
return errors.Wrap(err, "could not initialize director layers")
}
srv := proxy.NewServer(
proxy.WithServerConfig(conf.Proxy),
proxy.WithRedisConfig(conf.Redis),
proxy.WithDirectorLayers(layers...),
)
addrs, srvErrs := srv.Start(ctx.Context)
select {
case addr := <-addrs:
url := fmt.Sprintf("http://%s", addr.String())
url = strings.Replace(url, "0.0.0.0", "127.0.0.1", 1)
logger.Info(ctx.Context, "listening", logger.F("url", url))
case err = <-srvErrs:
return errors.WithStack(err)
}
if err = <-srvErrs; err != nil {
return errors.WithStack(err)
}
return nil
},
}
}
func initDirectorLayers(ctx context.Context, conf *config.Config) ([]director.Layer, error) {
layers := make([]director.Layer, 0)
queue, err := initQueueLayer(ctx, conf)
if err != nil {
return nil, errors.Wrap(err, "could not initialize queue layer")
}
layers = append(layers, queue)
return layers, nil
}
func initQueueLayer(ctx context.Context, conf *config.Config) (*queue.Queue, error) {
adapter, err := setup.NewQueueAdapter(ctx, conf.Redis)
if err != nil {
return nil, errors.WithStack(err)
}
return queue.New(adapter), nil
}

View File

@ -0,0 +1,18 @@
package server
import (
"forge.cadoles.com/cadoles/bouncer/internal/command/server/admin"
"forge.cadoles.com/cadoles/bouncer/internal/command/server/proxy"
"github.com/urfave/cli/v2"
)
func Root() *cli.Command {
return &cli.Command{
Name: "server",
Usage: "Server related commands",
Subcommands: []*cli.Command{
proxy.Root(),
admin.Root(),
},
}
}

View File

@ -0,0 +1,27 @@
package config
type AdminServerConfig struct {
HTTP HTTPConfig `yaml:"http"`
CORS CORSConfig `yaml:"cors"`
Auth AuthConfig `yaml:"auth"`
}
func NewDefaultAdminServerConfig() AdminServerConfig {
return AdminServerConfig{
HTTP: NewHTTPConfig("127.0.0.1", 8081),
CORS: NewDefaultCORSConfig(),
Auth: NewDefaultAuthConfig(),
}
}
type AuthConfig struct {
Issuer InterpolatedString `yaml:"issuer"`
PrivateKey InterpolatedString `yaml:"privateKey"`
}
func NewDefaultAuthConfig() AuthConfig {
return AuthConfig{
Issuer: "http://127.0.0.1:8081",
PrivateKey: "admin-key.json",
}
}

64
internal/config/config.go Normal file
View File

@ -0,0 +1,64 @@
package config
import (
"io"
"io/ioutil"
"github.com/pkg/errors"
"gopkg.in/yaml.v3"
)
// Config definition
type Config struct {
Admin AdminServerConfig `yaml:"admin"`
Proxy ProxyServerConfig `yaml:"proxy"`
Redis RedisConfig `yaml:"redis"`
Logger LoggerConfig `yaml:"logger"`
}
// NewFromFile retrieves the configuration from the given file
func NewFromFile(path string) (*Config, error) {
config := NewDefault()
data, err := ioutil.ReadFile(path)
if err != nil {
return nil, errors.Wrapf(err, "could not read file '%s'", path)
}
if err := yaml.Unmarshal(data, config); err != nil {
return nil, errors.Wrapf(err, "could not unmarshal configuration")
}
return config, nil
}
// NewDumpDefault dump the new default configuration
func NewDumpDefault() *Config {
config := NewDefault()
return config
}
// NewDefault return new default configuration
func NewDefault() *Config {
return &Config{
Admin: NewDefaultAdminServerConfig(),
Proxy: NewDefaultProxyServerConfig(),
Logger: NewDefaultLoggerConfig(),
Redis: NewDefaultRedisConfig(),
}
}
// Dump the given configuration in the given writer
func Dump(config *Config, w io.Writer) error {
data, err := yaml.Marshal(config)
if err != nil {
return errors.Wrap(err, "could not dump config")
}
if _, err := w.Write(data); err != nil {
return errors.WithStack(err)
}
return nil
}

View File

@ -0,0 +1,16 @@
package config
import (
"testing"
"github.com/pkg/errors"
)
func TestConfigLoad(t *testing.T) {
filepath := "./testdata/config.yml"
_, err := NewFromFile(filepath)
if err != nil {
t.Fatal(errors.WithStack(err))
}
}

20
internal/config/cors.go Normal file
View File

@ -0,0 +1,20 @@
package config
type CORSConfig struct {
AllowedOrigins InterpolatedStringSlice `yaml:"allowedOrigins"`
AllowCredentials InterpolatedBool `yaml:"allowCredentials"`
AllowedMethods InterpolatedStringSlice `yaml:"allowMethods"`
AllowedHeaders InterpolatedStringSlice `yaml:"allowedHeaders"`
Debug InterpolatedBool `yaml:"debug"`
}
// NewDefaultCorsConfig return the default CORS configuration.
func NewDefaultCORSConfig() CORSConfig {
return CORSConfig{
AllowedOrigins: InterpolatedStringSlice{"http://localhost:3001"},
AllowCredentials: true,
AllowedMethods: InterpolatedStringSlice{"POST", "GET", "PUT", "DELETE"},
AllowedHeaders: InterpolatedStringSlice{"Origin", "Accept", "Content-Type", "Authorization", "Sentry-Trace"},
Debug: false,
}
}

View File

@ -0,0 +1,125 @@
package config
import (
"os"
"regexp"
"strconv"
"github.com/pkg/errors"
"gopkg.in/yaml.v3"
)
var reVar = regexp.MustCompile(`^\${(\w+)}$`)
type InterpolatedString string
func (is *InterpolatedString) UnmarshalYAML(value *yaml.Node) error {
var str string
if err := value.Decode(&str); err != nil {
return errors.WithStack(err)
}
if match := reVar.FindStringSubmatch(str); len(match) > 0 {
*is = InterpolatedString(os.Getenv(match[1]))
} else {
*is = InterpolatedString(str)
}
return nil
}
type InterpolatedInt int
func (ii *InterpolatedInt) UnmarshalYAML(value *yaml.Node) error {
var str string
if err := value.Decode(&str); err != nil {
return errors.Wrapf(err, "could not decode value '%v' (line '%d') into string", value.Value, value.Line)
}
if match := reVar.FindStringSubmatch(str); len(match) > 0 {
str = os.Getenv(match[1])
}
intVal, err := strconv.ParseInt(str, 10, 32)
if err != nil {
return errors.Wrapf(err, "could not parse int '%v', line '%d'", str, value.Line)
}
*ii = InterpolatedInt(int(intVal))
return nil
}
type InterpolatedBool bool
func (ib *InterpolatedBool) UnmarshalYAML(value *yaml.Node) error {
var str string
if err := value.Decode(&str); err != nil {
return errors.Wrapf(err, "could not decode value '%v' (line '%d') into string", value.Value, value.Line)
}
if match := reVar.FindStringSubmatch(str); len(match) > 0 {
str = os.Getenv(match[1])
}
boolVal, err := strconv.ParseBool(str)
if err != nil {
return errors.Wrapf(err, "could not parse bool '%v', line '%d'", str, value.Line)
}
*ib = InterpolatedBool(boolVal)
return nil
}
type InterpolatedMap map[string]interface{}
func (im *InterpolatedMap) UnmarshalYAML(value *yaml.Node) error {
var data map[string]interface{}
if err := value.Decode(&data); err != nil {
return errors.Wrapf(err, "could not decode value '%v' (line '%d') into map", value.Value, value.Line)
}
for key, value := range data {
strVal, ok := value.(string)
if !ok {
continue
}
if match := reVar.FindStringSubmatch(strVal); len(match) > 0 {
strVal = os.Getenv(match[1])
}
data[key] = strVal
}
*im = data
return nil
}
type InterpolatedStringSlice []string
func (iss *InterpolatedStringSlice) UnmarshalYAML(value *yaml.Node) error {
var data []string
if err := value.Decode(&data); err != nil {
return errors.Wrapf(err, "could not decode value '%v' (line '%d') into map", value.Value, value.Line)
}
for index, value := range data {
if match := reVar.FindStringSubmatch(value); len(match) > 0 {
value = os.Getenv(match[1])
}
data[index] = value
}
*iss = data
return nil
}

13
internal/config/http.go Normal file
View File

@ -0,0 +1,13 @@
package config
type HTTPConfig struct {
Host InterpolatedString `yaml:"host"`
Port InterpolatedInt `yaml:"port"`
}
func NewHTTPConfig(host string, port int) HTTPConfig {
return HTTPConfig{
Host: InterpolatedString(host),
Port: InterpolatedInt(port),
}
}

15
internal/config/logger.go Normal file
View File

@ -0,0 +1,15 @@
package config
import "gitlab.com/wpetit/goweb/logger"
type LoggerConfig struct {
Level InterpolatedInt `yaml:"level"`
Format InterpolatedString `yaml:"format"`
}
func NewDefaultLoggerConfig() LoggerConfig {
return LoggerConfig{
Level: InterpolatedInt(logger.LevelInfo),
Format: InterpolatedString(logger.FormatHuman),
}
}

View File

@ -0,0 +1,11 @@
package config
type ProxyServerConfig struct {
HTTP HTTPConfig `yaml:"http"`
}
func NewDefaultProxyServerConfig() ProxyServerConfig {
return ProxyServerConfig{
HTTP: NewHTTPConfig("0.0.0.0", 8080),
}
}

19
internal/config/redis.go Normal file
View File

@ -0,0 +1,19 @@
package config
const (
RedisModeSimple = "simple"
RedisModeSentinel = "sentinel"
RedisModeCluster = "cluster"
)
type RedisConfig struct {
Adresses InterpolatedStringSlice `yaml:"addresses"`
Master InterpolatedString `yaml:"master"`
}
func NewDefaultRedisConfig() RedisConfig {
return RedisConfig{
Adresses: InterpolatedStringSlice{"localhost:6379"},
Master: "",
}
}

6
internal/config/testdata/config.yml vendored Normal file
View File

@ -0,0 +1,6 @@
logger:
level: 0
format: human
http:
host: "0.0.0.0"
port: 3000

View File

@ -0,0 +1,38 @@
package json
import (
"encoding/json"
"io"
"forge.cadoles.com/cadoles/bouncer/internal/format"
"github.com/pkg/errors"
)
const Format format.Format = "json"
func init() {
format.Register(Format, NewWriter())
}
type Writer struct{}
// Format implements format.Writer.
func (*Writer) Write(writer io.Writer, hints format.Hints, data ...any) error {
encoder := json.NewEncoder(writer)
if hints.OutputMode == format.OutputModeWide {
encoder.SetIndent("", " ")
}
if err := encoder.Encode(data); err != nil {
return errors.WithStack(err)
}
return nil
}
func NewWriter() *Writer {
return &Writer{}
}
var _ format.Writer = &Writer{}

49
internal/format/prop.go Normal file
View File

@ -0,0 +1,49 @@
package format
type PropHintName string
type PropHintFunc func() (PropHintName, any)
type Prop struct {
name string
label string
hints map[PropHintName]any
}
func (p *Prop) Name() string {
return p.name
}
func (p *Prop) Label() string {
return p.label
}
func NewProp(name, label string, funcs ...PropHintFunc) Prop {
hints := make(map[PropHintName]any)
for _, fn := range funcs {
name, value := fn()
hints[name] = value
}
return Prop{name, label, hints}
}
func WithPropHint(name PropHintName, value any) PropHintFunc {
return func() (PropHintName, any) {
return name, value
}
}
func PropHint[T any](p Prop, name PropHintName, defaultValue T) T {
rawValue, exists := p.hints[name]
if !exists {
return defaultValue
}
value, ok := rawValue.(T)
if !ok {
return defaultValue
}
return value
}

View File

@ -0,0 +1,46 @@
package format
import (
"io"
"github.com/pkg/errors"
)
type Format string
type Registry map[Format]Writer
var defaultRegistry = Registry{}
var ErrUnknownFormat = errors.New("unknown format")
func Write(format Format, writer io.Writer, hints Hints, data ...any) error {
formatWriter, exists := defaultRegistry[format]
if !exists {
return errors.WithStack(ErrUnknownFormat)
}
if hints.OutputMode == "" {
hints.OutputMode = OutputModeCompact
}
if err := formatWriter.Write(writer, hints, data...); err != nil {
return errors.WithStack(err)
}
return nil
}
func Available() []Format {
formats := make([]Format, 0, len(defaultRegistry))
for f := range defaultRegistry {
formats = append(formats, f)
}
return formats
}
func Register(format Format, writer Writer) {
defaultRegistry[format] = writer
}

View File

@ -0,0 +1,61 @@
package table
import (
"encoding/json"
"fmt"
"reflect"
"forge.cadoles.com/cadoles/bouncer/internal/format"
"github.com/pkg/errors"
)
const (
hintCompactModeMaxColumnWidth format.PropHintName = "compactModeMaxColumnWidth"
)
func WithCompactModeMaxColumnWidth(max int) format.PropHintFunc {
return format.WithPropHint(hintCompactModeMaxColumnWidth, max)
}
func getProps(d any) []format.Prop {
props := make([]format.Prop, 0)
v := reflect.Indirect(reflect.ValueOf(d))
typeOf := v.Type()
for i := 0; i < v.NumField(); i++ {
name := typeOf.Field(i).Name
props = append(props, format.NewProp(name, name))
}
return props
}
func getFieldValue(obj any, name string) string {
v := reflect.Indirect(reflect.ValueOf(obj))
fieldValue := v.FieldByName(name)
if !fieldValue.IsValid() {
return ""
}
switch fieldValue.Kind() {
case reflect.Map:
fallthrough
case reflect.Struct:
fallthrough
case reflect.Slice:
fallthrough
case reflect.Interface:
json, err := json.Marshal(fieldValue.Interface())
if err != nil {
panic(errors.WithStack(err))
}
return string(json)
default:
return fmt.Sprintf("%v", fieldValue)
}
}

View File

@ -0,0 +1,80 @@
package table
import (
"io"
"forge.cadoles.com/cadoles/bouncer/internal/format"
"github.com/jedib0t/go-pretty/v6/table"
)
const Format format.Format = "table"
const DefaultCompactModeMaxColumnWidth = 30
func init() {
format.Register(Format, NewWriter(DefaultCompactModeMaxColumnWidth))
}
type Writer struct {
compactModeMaxColumnWidth int
}
// Write implements format.Writer.
func (w *Writer) Write(writer io.Writer, hints format.Hints, data ...any) error {
t := table.NewWriter()
t.SetOutputMirror(writer)
var props []format.Prop
if hints.Props != nil {
props = hints.Props
} else {
if len(data) > 0 {
props = getProps(data[0])
} else {
props = make([]format.Prop, 0)
}
}
labels := table.Row{}
for _, p := range props {
labels = append(labels, p.Label())
}
t.AppendHeader(labels)
isCompactMode := hints.OutputMode == format.OutputModeCompact
for _, d := range data {
row := table.Row{}
for _, p := range props {
value := getFieldValue(d, p.Name())
compactModeMaxColumnWidth := format.PropHint(p,
hintCompactModeMaxColumnWidth,
w.compactModeMaxColumnWidth,
)
if isCompactMode && len(value) > compactModeMaxColumnWidth {
value = value[:compactModeMaxColumnWidth] + "..."
}
row = append(row, value)
}
t.AppendRow(row)
}
t.Render()
return nil
}
func NewWriter(compactModeMaxColumnWidth int) *Writer {
return &Writer{compactModeMaxColumnWidth}
}
var _ format.Writer = &Writer{}

View File

@ -0,0 +1,86 @@
package table
import (
"bytes"
"strings"
"testing"
"forge.cadoles.com/cadoles/bouncer/internal/format"
"github.com/pkg/errors"
)
type dummyItem struct {
MyString string
MyInt int
MySub subItem
}
type subItem struct {
MyBool bool
}
var dummyItems = []any{
dummyItem{
MyString: "Foo",
MyInt: 1,
MySub: subItem{
MyBool: false,
},
},
dummyItem{
MyString: "Bar",
MyInt: 0,
MySub: subItem{
MyBool: true,
},
},
}
func TestWriterNoHints(t *testing.T) {
var buf bytes.Buffer
writer := NewWriter(DefaultCompactModeMaxColumnWidth)
if err := writer.Write(&buf, format.Hints{}, dummyItems...); err != nil {
t.Fatalf("%+v", errors.WithStack(err))
}
expected := `+----------+-------+------------------+
| MYSTRING | MYINT | MYSUB |
+----------+-------+------------------+
| Foo | 1 | {"MyBool":false} |
| Bar | 0 | {"MyBool":true} |
+----------+-------+------------------+`
if e, g := strings.TrimSpace(expected), strings.TrimSpace(buf.String()); e != g {
t.Errorf("buf.String(): expected \n%v\ngot\n%v", e, g)
}
}
func TestWriterWithPropHints(t *testing.T) {
var buf bytes.Buffer
writer := NewWriter(DefaultCompactModeMaxColumnWidth)
hints := format.Hints{
Props: []format.Prop{
format.NewProp("MyString", "MyString"),
format.NewProp("MyInt", "MyInt"),
},
}
if err := writer.Write(&buf, hints, dummyItems...); err != nil {
t.Fatalf("%+v", errors.WithStack(err))
}
expected := `+----------+-------+
| MYSTRING | MYINT |
+----------+-------+
| Foo | 1 |
| Bar | 0 |
+----------+-------+`
if e, g := strings.TrimSpace(expected), strings.TrimSpace(buf.String()); e != g {
t.Errorf("buf.String(): expected \n%v\ngot\n%v", e, g)
}
}

19
internal/format/writer.go Normal file
View File

@ -0,0 +1,19 @@
package format
import "io"
type OutputMode string
const (
OutputModeWide OutputMode = "wide"
OutputModeCompact OutputMode = "compact"
)
type Hints struct {
Props []Prop
OutputMode OutputMode
}
type Writer interface {
Write(writer io.Writer, hints Hints, data ...any) error
}

View File

@ -0,0 +1,6 @@
package format
import (
_ "forge.cadoles.com/cadoles/bouncer/internal/format/json"
_ "forge.cadoles.com/cadoles/bouncer/internal/format/table"
)

140
internal/jwk/jwk.go Normal file
View File

@ -0,0 +1,140 @@
package jwk
import (
"crypto/rand"
"crypto/rsa"
"encoding/json"
"io/ioutil"
"os"
"github.com/btcsuite/btcd/btcutil/base58"
"github.com/lestrrat-go/jwx/v2/jwa"
"github.com/lestrrat-go/jwx/v2/jwk"
"github.com/lestrrat-go/jwx/v2/jws"
"github.com/pkg/errors"
)
const DefaultKeySize = 2048
type (
Key = jwk.Key
Set = jwk.Set
ParseOption = jwk.ParseOption
)
var (
FromRaw = jwk.FromRaw
NewSet = jwk.NewSet
)
const AlgorithmKey = jwk.AlgorithmKey
func Parse(src []byte, options ...jwk.ParseOption) (Set, error) {
return jwk.Parse(src, options...)
}
func PublicKeySet(keys ...jwk.Key) (jwk.Set, error) {
set := jwk.NewSet()
for _, k := range keys {
pubkey, err := k.PublicKey()
if err != nil {
return nil, errors.WithStack(err)
}
if err := pubkey.Set(jwk.AlgorithmKey, jwa.RS256); err != nil {
return nil, errors.WithStack(err)
}
if err := set.AddKey(pubkey); err != nil {
return nil, errors.WithStack(err)
}
}
return set, nil
}
func LoadOrGenerate(path string, size int) (jwk.Key, error) {
data, err := ioutil.ReadFile(path)
if err != nil && !errors.Is(err, os.ErrNotExist) {
return nil, errors.WithStack(err)
}
if errors.Is(err, os.ErrNotExist) {
key, err := Generate(size)
if err != nil {
return nil, errors.WithStack(err)
}
data, err = json.Marshal(key)
if err != nil {
return nil, errors.WithStack(err)
}
if err := ioutil.WriteFile(path, data, 0o640); err != nil {
return nil, errors.WithStack(err)
}
}
key, err := jwk.ParseKey(data)
if err != nil {
return nil, errors.WithStack(err)
}
return key, nil
}
func Generate(size int) (jwk.Key, error) {
privKey, err := rsa.GenerateKey(rand.Reader, size)
if err != nil {
return nil, errors.WithStack(err)
}
key, err := jwk.FromRaw(privKey)
if err != nil {
return nil, errors.WithStack(err)
}
return key, nil
}
func Sign(key jwk.Key, payload ...any) (string, error) {
json, err := json.Marshal(payload)
if err != nil {
return "", errors.WithStack(err)
}
rawSignature, err := jws.Sign(
nil,
jws.WithKey(jwa.RS256, key),
jws.WithDetachedPayload(json),
)
if err != nil {
return "", errors.WithStack(err)
}
signature := base58.Encode(rawSignature)
return signature, nil
}
func Verify(jwks jwk.Set, signature string, payload ...any) (bool, error) {
json, err := json.Marshal(payload)
if err != nil {
return false, errors.WithStack(err)
}
decoded := base58.Decode(signature)
_, err = jws.Verify(
decoded,
jws.WithKeySet(jwks, jws.WithRequireKid(false)),
jws.WithDetachedPayload(json),
)
if err != nil {
return false, errors.WithStack(err)
}
return true, nil
}

40
internal/jwk/jwk_test.go Normal file
View File

@ -0,0 +1,40 @@
package jwk
import (
"testing"
"github.com/pkg/errors"
)
func TestJWK(t *testing.T) {
privateKey, err := Generate(DefaultKeySize)
if err != nil {
t.Fatalf("%+v", errors.WithStack(err))
}
keySet, err := PublicKeySet(privateKey)
if err != nil {
t.Fatalf("%+v", errors.WithStack(err))
}
metadata := map[string]any{
"Foo": "bar",
"Test": 1,
}
signature, err := Sign(privateKey, metadata)
if err != nil {
t.Fatalf("%+v", errors.WithStack(err))
}
t.Logf("Signature: %s", signature)
matches, err := Verify(keySet, signature, metadata)
if err != nil {
t.Fatalf("%+v", errors.WithStack(err))
}
if !matches {
t.Error("signature should match")
}
}

View File

@ -0,0 +1,60 @@
package director
import (
"context"
"forge.cadoles.com/cadoles/bouncer/internal/store"
"github.com/pkg/errors"
)
type contextKey string
const (
contextKeyProxy contextKey = "proxy"
contextKeyLayers contextKey = "layers"
)
var (
errContextKeyNotFound = errors.New("context key not found")
errUnexpectedContextValue = errors.New("unexpected context value")
)
func withProxy(ctx context.Context, proxy *store.Proxy) context.Context {
return context.WithValue(ctx, contextKeyProxy, proxy)
}
func ctxProxy(ctx context.Context) (*store.Proxy, error) {
proxy, err := ctxValue[*store.Proxy](ctx, contextKeyProxy)
if err != nil {
return nil, errors.WithStack(err)
}
return proxy, nil
}
func withLayers(ctx context.Context, layers []*store.Layer) context.Context {
return context.WithValue(ctx, contextKeyLayers, layers)
}
func ctxLayers(ctx context.Context) ([]*store.Layer, error) {
layers, err := ctxValue[[]*store.Layer](ctx, contextKeyLayers)
if err != nil {
return nil, errors.WithStack(err)
}
return layers, nil
}
func ctxValue[T any](ctx context.Context, key contextKey) (T, error) {
raw := ctx.Value(key)
if raw == nil {
return *new(T), errors.WithStack(errContextKeyNotFound)
}
value, ok := raw.(T)
if !ok {
return *new(T), errors.WithStack(errUnexpectedContextValue)
}
return value, nil
}

View File

@ -0,0 +1,231 @@
package director
import (
"context"
"net/http"
"net/url"
"sort"
"forge.cadoles.com/Cadoles/go-proxy"
"forge.cadoles.com/Cadoles/go-proxy/wildcard"
"forge.cadoles.com/cadoles/bouncer/internal/store"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/logger"
)
type Director struct {
proxyRepository store.ProxyRepository
layerRepository store.LayerRepository
layerRegistry *LayerRegistry
}
func (d *Director) rewriteRequest(r *http.Request) (*http.Request, error) {
ctx := r.Context()
proxies, err := d.getProxies(ctx)
if err != nil {
return r, errors.WithStack(err)
}
var match *store.Proxy
MAIN:
for _, p := range proxies {
for _, from := range p.From {
if matches := wildcard.Match(r.Host, from); !matches {
continue
}
match = p
break MAIN
}
}
if match == nil {
return r, nil
}
toURL, err := url.Parse(match.To)
if err != nil {
return r, errors.WithStack(err)
}
r.URL.Host = toURL.Host
r.URL.Scheme = toURL.Scheme
ctx = logger.With(ctx,
logger.F("proxy", match.Name),
logger.F("host", r.Host),
logger.F("remoteAddr", r.RemoteAddr),
)
ctx = withProxy(ctx, match)
layers, err := d.getLayers(ctx, match.Name)
if err != nil {
return r, errors.WithStack(err)
}
ctx = withLayers(ctx, layers)
r = r.WithContext(ctx)
return r, nil
}
func (d *Director) getProxies(ctx context.Context) ([]*store.Proxy, error) {
headers, err := d.proxyRepository.QueryProxy(ctx, store.WithProxyQueryEnabled(true))
if err != nil {
return nil, errors.WithStack(err)
}
sort.Sort(store.ByProxyWeight(headers))
proxies := make([]*store.Proxy, 0, len(headers))
for _, h := range headers {
if !h.Enabled {
continue
}
proxy, err := d.proxyRepository.GetProxy(ctx, h.Name)
if err != nil {
return nil, errors.WithStack(err)
}
proxies = append(proxies, proxy)
}
return proxies, nil
}
func (d *Director) getLayers(ctx context.Context, proxyName store.ProxyName) ([]*store.Layer, error) {
headers, err := d.layerRepository.QueryLayers(ctx, proxyName, store.WithLayerQueryEnabled(true))
if err != nil {
return nil, errors.WithStack(err)
}
sort.Sort(store.ByLayerWeight(headers))
layers := make([]*store.Layer, 0, len(headers))
for _, h := range headers {
if !h.Enabled {
continue
}
layer, err := d.layerRepository.GetLayer(ctx, proxyName, h.Name)
if err != nil {
return nil, errors.WithStack(err)
}
layers = append(layers, layer)
}
return layers, nil
}
func (d *Director) RequestTransformer() proxy.RequestTransformer {
return func(r *http.Request) {
ctx := r.Context()
layers, err := ctxLayers(ctx)
if err != nil {
if errors.Is(err, errContextKeyNotFound) {
return
}
logger.Error(ctx, "could not retrieve layers from context", logger.E(errors.WithStack(err)))
return
}
for _, layer := range layers {
transformerLayer, ok := d.layerRegistry.GetRequestTransformer(layer.Type)
if !ok {
continue
}
transformer := transformerLayer.RequestTransformer(layer)
transformer(r)
}
}
}
func (d *Director) ResponseTransformer() proxy.ResponseTransformer {
return func(r *http.Response) error {
ctx := r.Request.Context()
layers, err := ctxLayers(ctx)
if err != nil {
if errors.Is(err, errContextKeyNotFound) {
return nil
}
return errors.WithStack(err)
}
for _, layer := range layers {
transformerLayer, ok := d.layerRegistry.GetResponseTransformer(layer.Type)
if !ok {
continue
}
transformer := transformerLayer.ResponseTransformer(layer)
if err := transformer(r); err != nil {
return errors.WithStack(err)
}
}
return nil
}
}
func (d *Director) Middleware() proxy.Middleware {
return func(next http.Handler) http.Handler {
fn := func(w http.ResponseWriter, r *http.Request) {
r, err := d.rewriteRequest(r)
if err != nil {
logger.Error(r.Context(), "could not rewrite request", logger.E(errors.WithStack(err)))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
ctx := r.Context()
layers, err := ctxLayers(ctx)
if err != nil {
if errors.Is(err, errContextKeyNotFound) {
return
}
logger.Error(ctx, "could not retrieve proxy and layers from context", logger.E(errors.WithStack(err)))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
httpMiddlewares := make([]proxy.Middleware, 0)
for _, layer := range layers {
middleware, ok := d.layerRegistry.GetMiddleware(layer.Type)
if !ok {
continue
}
httpMiddlewares = append(httpMiddlewares, middleware.Middleware(layer))
}
handler := createMiddlewareChain(next, httpMiddlewares)
handler.ServeHTTP(w, r)
}
return http.HandlerFunc(fn)
}
}
func New(proxyRepository store.ProxyRepository, layerRepository store.LayerRepository, layers ...Layer) *Director {
registry := NewLayerRegistry(layers...)
return &Director{proxyRepository, layerRepository, registry}
}

View File

@ -0,0 +1,104 @@
package director
import (
"forge.cadoles.com/Cadoles/go-proxy"
"forge.cadoles.com/cadoles/bouncer/internal/store"
)
type Layer interface {
LayerType() store.LayerType
}
type MiddlewareLayer interface {
Layer
Middleware(layer *store.Layer) proxy.Middleware
}
type RequestTransformerLayer interface {
Layer
RequestTransformer(layer *store.Layer) proxy.RequestTransformer
}
type ResponseTransformerLayer interface {
Layer
ResponseTransformer(layer *store.Layer) proxy.ResponseTransformer
}
type LayerRegistry struct {
index map[store.LayerType]Layer
}
func (r *LayerRegistry) GetLayer(layerType store.LayerType) (Layer, bool) {
layer, exists := r.index[layerType]
if !exists {
return nil, false
}
return layer, true
}
func (r *LayerRegistry) getLayerAsAny(layerType store.LayerType) (any, bool) {
return r.GetLayer(layerType)
}
func (r *LayerRegistry) GetMiddleware(layerType store.LayerType) (MiddlewareLayer, bool) {
layer, exists := r.getLayerAsAny(layerType)
if !exists {
return nil, false
}
middleware, ok := layer.(MiddlewareLayer)
if !ok {
return nil, false
}
return middleware, true
}
func (r *LayerRegistry) GetResponseTransformer(layerType store.LayerType) (ResponseTransformerLayer, bool) {
layer, exists := r.getLayerAsAny(layerType)
if !exists {
return nil, false
}
transformer, ok := layer.(ResponseTransformerLayer)
if !ok {
return nil, false
}
return transformer, true
}
func (r *LayerRegistry) GetRequestTransformer(layerType store.LayerType) (RequestTransformerLayer, bool) {
layer, exists := r.getLayerAsAny(layerType)
if !exists {
return nil, false
}
transformer, ok := layer.(RequestTransformerLayer)
if !ok {
return nil, false
}
return transformer, true
}
func (r *LayerRegistry) Load(layers ...Layer) {
index := make(map[store.LayerType]Layer)
for _, l := range layers {
layerType := l.LayerType()
index[layerType] = l
}
r.index = index
}
func NewLayerRegistry(layers ...Layer) *LayerRegistry {
registry := &LayerRegistry{
index: make(map[store.LayerType]Layer),
}
registry.Load(layers...)
return registry
}

View File

@ -0,0 +1,18 @@
package director
import (
"net/http"
"forge.cadoles.com/Cadoles/go-proxy"
"forge.cadoles.com/Cadoles/go-proxy/util"
)
func createMiddlewareChain(handler http.Handler, middlewares []proxy.Middleware) http.Handler {
util.Reverse(middlewares)
for _, m := range middlewares {
handler = m(handler)
}
return handler
}

42
internal/proxy/init.go Normal file
View File

@ -0,0 +1,42 @@
package proxy
import (
"context"
"forge.cadoles.com/cadoles/bouncer/internal/setup"
"github.com/pkg/errors"
)
func (s *Server) initRepositories(ctx context.Context) error {
if err := s.initProxyRepository(ctx); err != nil {
return errors.WithStack(err)
}
if err := s.initLayerRepository(ctx); err != nil {
return errors.WithStack(err)
}
return nil
}
func (s *Server) initProxyRepository(ctx context.Context) error {
proxyRepository, err := setup.NewProxyRepository(ctx, s.redisConfig)
if err != nil {
return errors.WithStack(err)
}
s.proxyRepository = proxyRepository
return nil
}
func (s *Server) initLayerRepository(ctx context.Context) error {
layerRepository, err := setup.NewLayerRepository(ctx, s.redisConfig)
if err != nil {
return errors.WithStack(err)
}
s.layerRepository = layerRepository
return nil
}

40
internal/proxy/option.go Normal file
View File

@ -0,0 +1,40 @@
package proxy
import (
"forge.cadoles.com/cadoles/bouncer/internal/config"
"forge.cadoles.com/cadoles/bouncer/internal/proxy/director"
)
type Option struct {
ServerConfig config.ProxyServerConfig
RedisConfig config.RedisConfig
DirectorLayers []director.Layer
}
type OptionFunc func(*Option)
func defaultOption() *Option {
return &Option{
ServerConfig: config.NewDefaultProxyServerConfig(),
RedisConfig: config.NewDefaultRedisConfig(),
DirectorLayers: make([]director.Layer, 0),
}
}
func WithServerConfig(conf config.ProxyServerConfig) OptionFunc {
return func(opt *Option) {
opt.ServerConfig = conf
}
}
func WithRedisConfig(conf config.RedisConfig) OptionFunc {
return func(opt *Option) {
opt.RedisConfig = conf
}
}
func WithDirectorLayers(layers ...director.Layer) OptionFunc {
return func(opt *Option) {
opt.DirectorLayers = layers
}
}

118
internal/proxy/server.go Normal file
View File

@ -0,0 +1,118 @@
package proxy
import (
"context"
"fmt"
"log"
"net"
"net/http"
"forge.cadoles.com/Cadoles/go-proxy"
bouncerChi "forge.cadoles.com/cadoles/bouncer/internal/chi"
"forge.cadoles.com/cadoles/bouncer/internal/config"
"forge.cadoles.com/cadoles/bouncer/internal/proxy/director"
"forge.cadoles.com/cadoles/bouncer/internal/store"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/logger"
)
type Server struct {
serverConfig config.ProxyServerConfig
redisConfig config.RedisConfig
directorLayers []director.Layer
proxyRepository store.ProxyRepository
layerRepository store.LayerRepository
}
func (s *Server) Start(ctx context.Context) (<-chan net.Addr, <-chan error) {
errs := make(chan error)
addrs := make(chan net.Addr)
go s.run(ctx, addrs, errs)
return addrs, errs
}
func (s *Server) run(parentCtx context.Context, addrs chan net.Addr, errs chan error) {
defer func() {
close(errs)
close(addrs)
}()
ctx, cancel := context.WithCancel(parentCtx)
defer cancel()
if err := s.initRepositories(ctx); err != nil {
errs <- errors.WithStack(err)
return
}
listener, err := net.Listen("tcp", fmt.Sprintf("%s:%d", s.serverConfig.HTTP.Host, s.serverConfig.HTTP.Port))
if err != nil {
errs <- errors.WithStack(err)
return
}
addrs <- listener.Addr()
defer func() {
if err := listener.Close(); err != nil && !errors.Is(err, net.ErrClosed) {
errs <- errors.WithStack(err)
}
}()
go func() {
<-ctx.Done()
if err := listener.Close(); err != nil && !errors.Is(err, net.ErrClosed) {
log.Printf("%+v", errors.WithStack(err))
}
}()
router := chi.NewRouter()
logger.Info(ctx, "http server listening")
director := director.New(
s.proxyRepository,
s.layerRepository,
s.directorLayers...,
)
router.Use(middleware.RequestLogger(bouncerChi.NewLogFormatter()))
router.Use(director.Middleware())
handler := proxy.New(
proxy.WithRequestTransformers(
director.RequestTransformer(),
),
proxy.WithResponseTransformers(
director.ResponseTransformer(),
),
)
router.Handle("/*", handler)
if err := http.Serve(listener, router); err != nil && !errors.Is(err, net.ErrClosed) {
errs <- errors.WithStack(err)
}
logger.Info(ctx, "http server exiting")
}
func NewServer(funcs ...OptionFunc) *Server {
opt := defaultOption()
for _, fn := range funcs {
fn(opt)
}
return &Server{
serverConfig: opt.ServerConfig,
redisConfig: opt.RedisConfig,
directorLayers: opt.DirectorLayers,
}
}

16
internal/queue/adapter.go Normal file
View File

@ -0,0 +1,16 @@
package queue
import (
"context"
"time"
)
type Status struct {
Sessions int64
}
type Adapter interface {
Touch(ctx context.Context, queueName string, sessionId string) (int64, error)
Status(ctx context.Context, queueName string) (*Status, error)
Refresh(ctx context.Context, queueName string, keepAlive time.Duration) error
}

View File

@ -0,0 +1,48 @@
package queue
import (
"reflect"
"time"
"forge.cadoles.com/cadoles/bouncer/internal/store"
"github.com/mitchellh/mapstructure"
"github.com/pkg/errors"
)
type LayerOptions struct {
Capacity int64 `mapstructure:"capacity"`
Matchers []string `mapstructure:"matchers"`
KeepAlive time.Duration `mapstructure:"keepAlive"`
}
func fromStoreOptions(storeOptions store.LayerOptions) (*LayerOptions, error) {
layerOptions := LayerOptions{
Capacity: 1000,
Matchers: []string{"*"},
KeepAlive: 30 * time.Second,
}
config := mapstructure.DecoderConfig{
DecodeHook: stringToDurationHook,
Result: &layerOptions,
}
decoder, err := mapstructure.NewDecoder(&config)
if err != nil {
return nil, err
}
if err := decoder.Decode(storeOptions); err != nil {
return nil, errors.WithStack(err)
}
return &layerOptions, nil
}
func stringToDurationHook(f reflect.Type, t reflect.Type, data interface{}) (interface{}, error) {
if t == reflect.TypeOf(*new(time.Duration)) && f == reflect.TypeOf("") {
return time.ParseDuration(data.(string))
}
return data, nil
}

View File

@ -0,0 +1,9 @@
package queue
type Options struct{}
type OptionFunc func(*Options)
func defaultOptions() *Options {
return &Options{}
}

143
internal/queue/queue.go Normal file
View File

@ -0,0 +1,143 @@
package queue
import (
"context"
"fmt"
"net/http"
"sync/atomic"
"time"
"forge.cadoles.com/Cadoles/go-proxy"
"forge.cadoles.com/cadoles/bouncer/internal/proxy/director"
"forge.cadoles.com/cadoles/bouncer/internal/store"
"github.com/google/uuid"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/logger"
)
const LayerType store.LayerType = "queue"
type Queue struct {
adapter Adapter
refreshJobRunning uint32
}
// LayerType implements director.MiddlewareLayer
func (q *Queue) LayerType() store.LayerType {
return LayerType
}
// Middleware implements director.MiddlewareLayer
func (q *Queue) Middleware(layer *store.Layer) proxy.Middleware {
return func(h http.Handler) http.Handler {
fn := func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
options, err := fromStoreOptions(layer.Options)
if err != nil {
logger.Error(ctx, "could not parse layer options", logger.E(errors.WithStack(err)))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
cookieName := q.getCookieName(layer.Name)
cookie, err := r.Cookie(cookieName)
if err != nil && !errors.Is(err, http.ErrNoCookie) {
logger.Error(ctx, "could not retrieve cookie", logger.E(errors.WithStack(err)))
}
if cookie == nil {
cookie = &http.Cookie{
Name: cookieName,
Value: uuid.NewString(),
Path: "/",
}
w.Header().Add("Set-Cookie", cookie.String())
}
sessionID := cookie.Value
queueName := string(layer.Name)
q.refreshQueue(queueName, options.KeepAlive)
rank, err := q.adapter.Touch(ctx, queueName, sessionID)
if err != nil {
logger.Error(ctx, "could not retrieve session rank", logger.E(errors.WithStack(err)))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
if rank >= options.Capacity {
q.renderQueuePage(w, r, queueName, options, rank)
return
}
ctx = logger.With(ctx,
logger.F("queueSessionId", sessionID),
logger.F("queueName", queueName),
logger.F("queueSessionRank", rank),
)
r = r.WithContext(ctx)
h.ServeHTTP(w, r)
}
return http.HandlerFunc(fn)
}
}
func (q *Queue) renderQueuePage(w http.ResponseWriter, r *http.Request, queueName string, options *LayerOptions, rank int64) {
ctx := r.Context()
status, err := q.adapter.Status(ctx, queueName)
if err != nil {
logger.Error(ctx, "could not retrieve queue status", logger.E(errors.WithStack(err)))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
http.Error(w, fmt.Sprintf("queued (rank: %d, status: %d/%d)", rank+1, status.Sessions, options.Capacity), http.StatusServiceUnavailable)
}
func (q *Queue) refreshQueue(queueName string, keepAlive time.Duration) {
if !atomic.CompareAndSwapUint32(&q.refreshJobRunning, 0, 1) {
return
}
go func() {
defer atomic.StoreUint32(&q.refreshJobRunning, 0)
ctx, cancel := context.WithTimeout(context.Background(), keepAlive*2)
defer cancel()
if err := q.adapter.Refresh(ctx, queueName, keepAlive); err != nil {
logger.Error(ctx, "could not refresh queue",
logger.E(errors.WithStack(err)),
logger.F("queue", queueName),
)
}
}()
}
func (q *Queue) getCookieName(layerName store.LayerName) string {
return fmt.Sprintf("_%s_%s", LayerType, layerName)
}
func New(adapter Adapter, funcs ...OptionFunc) *Queue {
opts := defaultOptions()
for _, fn := range funcs {
fn(opts)
}
return &Queue{
adapter: adapter,
}
}
var _ director.MiddlewareLayer = &Queue{}

View File

@ -0,0 +1,167 @@
package redis
import (
"context"
"strconv"
"strings"
"time"
"forge.cadoles.com/cadoles/bouncer/internal/queue"
"github.com/pkg/errors"
"github.com/redis/go-redis/v9"
)
const (
keyPrefixQueue = "queue"
)
type Adapter struct {
client redis.UniversalClient
txMaxRetry int
}
// Refresh implements queue.Adapter
func (a *Adapter) Refresh(ctx context.Context, queueName string, keepAlive time.Duration) error {
lastSeenKey := lastSeenKey(queueName)
rankKey := rankKey(queueName)
err := withTx(ctx, a.client, func(ctx context.Context, tx *redis.Tx) error {
expires := time.Now().UTC().Add(-keepAlive)
cmd := tx.ZRangeByScore(ctx, lastSeenKey, &redis.ZRangeBy{
Min: "0",
Max: strconv.FormatInt(expires.Unix(), 10),
})
members, err := cmd.Result()
if err != nil {
return errors.WithStack(err)
}
if len(members) == 0 {
return nil
}
anyMembers := make([]any, len(members))
for i, m := range members {
anyMembers[i] = m
}
if err := tx.ZRem(ctx, rankKey, anyMembers...).Err(); err != nil {
return errors.WithStack(err)
}
if err := tx.ZRem(ctx, lastSeenKey, anyMembers...).Err(); err != nil {
return errors.WithStack(err)
}
return nil
}, rankKey, lastSeenKey)
if err != nil {
return errors.WithStack(err)
}
return nil
}
// Touch implements queue.Adapter
func (a *Adapter) Touch(ctx context.Context, queueName string, sessionId string) (int64, error) {
lastSeenKey := lastSeenKey(queueName)
rankKey := rankKey(queueName)
var rank int64
retry := a.txMaxRetry
for retry > 0 {
err := withTx(ctx, a.client, func(ctx context.Context, tx *redis.Tx) error {
now := time.Now().UTC().Unix()
err := tx.ZAddNX(ctx, rankKey, redis.Z{Score: float64(now), Member: sessionId}).Err()
if err != nil {
return errors.WithStack(err)
}
err = tx.ZAdd(ctx, lastSeenKey, redis.Z{Score: float64(now), Member: sessionId}).Err()
if err != nil {
return errors.WithStack(err)
}
val, err := tx.ZRank(ctx, rankKey, sessionId).Result()
if err != nil {
return errors.WithStack(err)
}
rank = val
return nil
}, rankKey, lastSeenKey)
if err != nil {
if errors.Is(err, redis.Nil) && retry > 0 {
retry--
continue
}
return 0, errors.WithStack(err)
}
break
}
return rank, nil
}
// Status implements queue.Adapter
func (a *Adapter) Status(ctx context.Context, queueName string) (*queue.Status, error) {
rankKey := rankKey(queueName)
status := &queue.Status{}
cmd := a.client.ZCard(ctx, rankKey)
if err := cmd.Err(); err != nil {
return nil, errors.WithStack(err)
}
status.Sessions = cmd.Val()
return status, nil
}
func NewAdapter(client redis.UniversalClient, txMaxRetry int) *Adapter {
return &Adapter{
client: client,
txMaxRetry: txMaxRetry,
}
}
var _ queue.Adapter = &Adapter{}
func key(parts ...string) string {
return strings.Join(parts, ":")
}
func rankKey(queueName string) string {
return key(keyPrefixQueue, queueName, "rank")
}
func lastSeenKey(queueName string) string {
return key(keyPrefixQueue, queueName, "last_seen")
}
func withTx(ctx context.Context, client redis.UniversalClient, fn func(ctx context.Context, tx *redis.Tx) error, keys ...string) error {
txf := func(tx *redis.Tx) error {
if err := fn(ctx, tx); err != nil {
return errors.WithStack(err)
}
return nil
}
err := client.Watch(ctx, txf, keys...)
if err != nil {
return errors.WithStack(err)
}
return nil
}

20
internal/queue/schema.go Normal file
View File

@ -0,0 +1,20 @@
package queue
import (
_ "embed"
"forge.cadoles.com/cadoles/bouncer/internal/schema"
"github.com/pkg/errors"
)
//go:embed schema/layer-options.json
var rawLayerOptionsSchema []byte
func init() {
layerOptionsSchema, err := schema.Parse(rawLayerOptionsSchema)
if err != nil {
panic(errors.Wrap(err, "could not parse queue layer options schema"))
}
schema.RegisterLayerOptionsSchema(LayerType, layerOptionsSchema)
}

View File

@ -0,0 +1,21 @@
{
"$id": "https://forge.cadoles.com/cadoles/bouncer/schemas/queue-layer-options",
"title": "Queue layer options",
"type": "object",
"properties": {
"capacity": {
"type": "number",
"minimum": 0
},
"matchers": {
"type": "array",
"items": {
"type": "string"
}
},
"keepAlive": {
"type": "string"
}
},
"additionalProperties": false
}

33
internal/schema/error.go Normal file
View File

@ -0,0 +1,33 @@
package schema
import (
"errors"
"fmt"
"github.com/qri-io/jsonschema"
)
var (
ErrSchemaNotFound = errors.New("schema not found")
ErrInvalidData = errors.New("invalid data")
)
type InvalidDataError struct {
keyErrors []jsonschema.KeyError
}
func (e *InvalidDataError) Is(err error) bool {
return err == ErrInvalidData
}
func (e *InvalidDataError) Error() string {
return fmt.Sprintf("%s: %s", ErrInvalidData.Error(), e.keyErrors)
}
func (e *InvalidDataError) KeyErrors() []jsonschema.KeyError {
return e.keyErrors
}
func NewInvalidDataError(keyErrors ...jsonschema.KeyError) *InvalidDataError {
return &InvalidDataError{keyErrors}
}

17
internal/schema/load.go Normal file
View File

@ -0,0 +1,17 @@
package schema
import (
"encoding/json"
"github.com/pkg/errors"
"github.com/qri-io/jsonschema"
)
func Parse(data []byte) (*jsonschema.Schema, error) {
var schema jsonschema.Schema
if err := json.Unmarshal(data, &schema); err != nil {
return nil, errors.WithStack(err)
}
return &schema, nil
}

View File

@ -0,0 +1,56 @@
package schema
import (
"context"
"forge.cadoles.com/cadoles/bouncer/internal/store"
"github.com/pkg/errors"
"github.com/qri-io/jsonschema"
)
var defaultRegistry = NewRegistry()
func RegisterLayerOptionsSchema(layerType store.LayerType, schema *jsonschema.Schema) {
defaultRegistry.RegisterLayerOptionsSchema(layerType, schema)
}
func ValidateLayerOptions(ctx context.Context, layerType store.LayerType, options *store.LayerOptions) error {
if err := defaultRegistry.ValidateLayerOptions(ctx, layerType, options); err != nil {
return errors.WithStack(err)
}
return nil
}
type Registry struct {
layerOptionSchemas map[store.LayerType]*jsonschema.Schema
}
func (r *Registry) RegisterLayerOptionsSchema(layerType store.LayerType, schema *jsonschema.Schema) {
r.layerOptionSchemas[layerType] = schema
}
func (r *Registry) ValidateLayerOptions(ctx context.Context, layerType store.LayerType, options *store.LayerOptions) error {
schema, exists := r.layerOptionSchemas[layerType]
if !exists {
return errors.WithStack(ErrSchemaNotFound)
}
rawOptions := func(opts *store.LayerOptions) map[string]any {
return *opts
}(options)
state := schema.Validate(ctx, rawOptions)
if len(*state.Errs) > 0 {
return errors.WithStack(NewInvalidDataError(*state.Errs...))
}
return nil
}
func NewRegistry() *Registry {
return &Registry{
layerOptionSchemas: make(map[store.LayerType]*jsonschema.Schema),
}
}

View File

@ -0,0 +1,28 @@
package setup
import (
"context"
"forge.cadoles.com/cadoles/bouncer/internal/config"
"forge.cadoles.com/cadoles/bouncer/internal/store"
redisStore "forge.cadoles.com/cadoles/bouncer/internal/store/redis"
"github.com/redis/go-redis/v9"
)
func NewProxyRepository(ctx context.Context, conf config.RedisConfig) (store.ProxyRepository, error) {
rdb := redis.NewUniversalClient(&redis.UniversalOptions{
Addrs: conf.Adresses,
MasterName: string(conf.Master),
})
return redisStore.NewProxyRepository(rdb), nil
}
func NewLayerRepository(ctx context.Context, conf config.RedisConfig) (store.LayerRepository, error) {
rdb := redis.NewUniversalClient(&redis.UniversalOptions{
Addrs: conf.Adresses,
MasterName: string(conf.Master),
})
return redisStore.NewLayerRepository(rdb), nil
}

View File

@ -0,0 +1,20 @@
package setup
import (
"context"
"forge.cadoles.com/cadoles/bouncer/internal/config"
"forge.cadoles.com/cadoles/bouncer/internal/queue"
"github.com/redis/go-redis/v9"
queueRedis "forge.cadoles.com/cadoles/bouncer/internal/queue/redis"
)
func NewQueueAdapter(ctx context.Context, conf config.RedisConfig) (queue.Adapter, error) {
rdb := redis.NewUniversalClient(&redis.UniversalOptions{
Addrs: conf.Adresses,
MasterName: string(conf.Master),
})
return queueRedis.NewAdapter(rdb, 2), nil
}

8
internal/store/error.go Normal file
View File

@ -0,0 +1,8 @@
package store
import "errors"
var (
ErrAlreadyExist = errors.New("already exist")
ErrNotFound = errors.New("not found")
)

25
internal/store/layer.go Normal file
View File

@ -0,0 +1,25 @@
package store
import "time"
type (
LayerName Name
LayerType string
)
type LayerHeader struct {
Proxy ProxyName `json:"proxy"`
Name LayerName `json:"name"`
Type LayerType `json:"type"`
Weight int `json:"weight"`
Enabled bool `json:"enabled"`
}
type Layer struct {
LayerHeader
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
Options LayerOptions `json:"options"`
}

View File

@ -0,0 +1,78 @@
package store
import (
"context"
)
type LayerOptions map[string]any
type LayerRepository interface {
CreateLayer(ctx context.Context, proxyName ProxyName, layerName LayerName, layerType LayerType, options LayerOptions) (*Layer, error)
UpdateLayer(ctx context.Context, proxyName ProxyName, layerName LayerName, funcs ...UpdateLayerOptionFunc) (*Layer, error)
DeleteLayer(ctx context.Context, proxyName ProxyName, layerName LayerName) error
GetLayer(ctx context.Context, proxyName ProxyName, layerName LayerName) (*Layer, error)
QueryLayers(ctx context.Context, proxyName ProxyName, funcs ...QueryLayerOptionFunc) ([]*LayerHeader, error)
}
type QueryLayerOptionFunc func(*QueryLayerOptions)
type QueryLayerOptions struct {
Type *LayerType
Name *LayerName
Enabled *bool
}
func DefaultQueryLayerOptions() *QueryLayerOptions {
funcs := []QueryLayerOptionFunc{}
opts := &QueryLayerOptions{}
for _, fn := range funcs {
fn(opts)
}
return opts
}
func WithLayerQueryType(layerType LayerType) QueryLayerOptionFunc {
return func(o *QueryLayerOptions) {
o.Type = &layerType
}
}
func WithLayerQueryName(layerName LayerName) QueryLayerOptionFunc {
return func(o *QueryLayerOptions) {
o.Name = &layerName
}
}
func WithLayerQueryEnabled(enabled bool) QueryLayerOptionFunc {
return func(o *QueryLayerOptions) {
o.Enabled = &enabled
}
}
type UpdateLayerOptionFunc func(*UpdateLayerOptions)
type UpdateLayerOptions struct {
Enabled *bool
Weight *int
Options *LayerOptions
}
func WithLayerUpdateEnabled(enabled bool) UpdateLayerOptionFunc {
return func(o *UpdateLayerOptions) {
o.Enabled = &enabled
}
}
func WithLayerUpdateWeight(weight int) UpdateLayerOptionFunc {
return func(o *UpdateLayerOptions) {
o.Weight = &weight
}
}
func WithLayerUpdateOptions(options LayerOptions) UpdateLayerOptionFunc {
return func(o *UpdateLayerOptions) {
o.Options = &options
}
}

14
internal/store/name.go Normal file
View File

@ -0,0 +1,14 @@
package store
import "github.com/pkg/errors"
type Name string
var ErrEmptyName = errors.New("name cannot be empty")
func ValidateName(name string) (Name, error) {
if name == "" {
return "", errors.WithStack(ErrEmptyName)
}
return Name(name), nil
}

22
internal/store/proxy.go Normal file
View File

@ -0,0 +1,22 @@
package store
import (
"time"
)
type ProxyName Name
type ProxyHeader struct {
Name ProxyName `json:"name"`
Weight int `json:"weight"`
Enabled bool `json:"enabled"`
}
type Proxy struct {
ProxyHeader
To string `json:"to"`
From []string `json:"from"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}

Some files were not shown because too many files have changed in this diff Show More