feat: initial commit
All checks were successful
Cadoles/bouncer/pipeline/head This commit looks good
All checks were successful
Cadoles/bouncer/pipeline/head This commit looks good
This commit is contained in:
101
internal/admin/authz.go
Normal file
101
internal/admin/authz.go
Normal 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)),
|
||||
)
|
||||
}
|
42
internal/admin/init.go
Normal file
42
internal/admin/init.go
Normal 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.initQueueRepository(ctx); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
if err := s.initProxyRepository(ctx); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) initQueueRepository(ctx context.Context) error {
|
||||
queueRepository, err := setup.NewQueueRepository(ctx, s.redisConfig)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
s.queueRepository = queueRepository
|
||||
|
||||
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
|
||||
}
|
31
internal/admin/option.go
Normal file
31
internal/admin/option.go
Normal 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
|
||||
}
|
||||
}
|
234
internal/admin/proxy.go
Normal file
234
internal/admin/proxy.go
Normal file
@ -0,0 +1,234 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"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) {
|
||||
limit, ok := getIntQueryParam(w, r, "limit", 10)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
offset, ok := getIntQueryParam(w, r, "offset", 0)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
options := []store.QueryProxyOptionFunc{
|
||||
store.WithProxyQueryLimit(int(limit)),
|
||||
store.WithProxyQueryOffset(int(offset)),
|
||||
}
|
||||
|
||||
ids, ok := getStringableSliceValues[store.ProxyID](w, r, "ids", nil)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
if ids != nil {
|
||||
options = append(options, store.WithProxyQueryIDs(ids...))
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
api.DataResponse(w, http.StatusOK, QueryProxyResponse{
|
||||
Proxies: proxies,
|
||||
})
|
||||
}
|
||||
|
||||
type GetProxyResponse struct {
|
||||
Proxy *store.Proxy `json:"proxy"`
|
||||
}
|
||||
|
||||
func (s *Server) getProxy(w http.ResponseWriter, r *http.Request) {
|
||||
proxyID, ok := getProxyID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
|
||||
proxy, err := s.proxyRepository.GetProxy(ctx, proxyID)
|
||||
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 {
|
||||
ProxyID store.ProxyID `json:"proxyId"`
|
||||
}
|
||||
|
||||
func (s *Server) deleteProxy(w http.ResponseWriter, r *http.Request) {
|
||||
proxyID, ok := getProxyID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
|
||||
if err := s.proxyRepository.DeleteProxy(ctx, proxyID); 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{
|
||||
ProxyID: proxyID,
|
||||
})
|
||||
}
|
||||
|
||||
type CreateProxyRequest struct {
|
||||
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
|
||||
}
|
||||
|
||||
to, err := url.Parse(createProxyReq.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
|
||||
}
|
||||
|
||||
proxy, err := s.proxyRepository.CreateProxy(ctx, to, createProxyReq.From...)
|
||||
if err != nil {
|
||||
logger.Error(ctx, "could not update agent", 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{}
|
||||
|
||||
func (s *Server) updateProxy(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func getProxyID(w http.ResponseWriter, r *http.Request) (store.ProxyID, bool) {
|
||||
rawProxyID := chi.URLParam(r, "proxyID")
|
||||
|
||||
proxyID, err := store.ParseProxyID(rawProxyID)
|
||||
if err != nil {
|
||||
logger.Error(r.Context(), "could not parse proxy id", logger.E(errors.WithStack(err)))
|
||||
api.ErrorResponse(w, http.StatusBadRequest, api.ErrCodeMalformedRequest, nil)
|
||||
|
||||
return "", false
|
||||
}
|
||||
|
||||
return proxyID, 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) ([]T, bool) {
|
||||
rawValue := r.URL.Query().Get(param)
|
||||
|
||||
if rawValue != "" {
|
||||
rawValues := strings.Split(rawValue, ",")
|
||||
ids := make([]T, 0, len(rawValues))
|
||||
|
||||
for _, rv := range rawValues {
|
||||
id, err := store.ParseID[T](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
|
||||
}
|
||||
|
||||
ids = append(ids, id)
|
||||
}
|
||||
|
||||
return ids, true
|
||||
}
|
||||
|
||||
return defaultValue, true
|
||||
}
|
140
internal/admin/server.go
Normal file
140
internal/admin/server.go
Normal file
@ -0,0 +1,140 @@
|
||||
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/queue"
|
||||
"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
|
||||
queueRepository queue.Repository
|
||||
proxyRepository store.ProxyRepository
|
||||
}
|
||||
|
||||
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("/{proxyID}", s.getProxy)
|
||||
r.With(assertWriteAccess).Put("/{proxyID}", s.updateProxy)
|
||||
r.With(assertWriteAccess).Delete("/{proxyID}", s.deleteProxy)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
72
internal/auth/jwt/authenticator.go
Normal file
72
internal/auth/jwt/authenticator.go
Normal 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
62
internal/auth/jwt/jwt.go
Normal 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
32
internal/auth/jwt/user.go
Normal 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{}
|
79
internal/auth/middleware.go
Normal file
79
internal/auth/middleware.go
Normal 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)
|
||||
}
|
||||
}
|
144
internal/client/client.go
Normal file
144
internal/client/client.go
Normal 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,
|
||||
}
|
||||
}
|
29
internal/client/create_proxy.go
Normal file
29
internal/client/create_proxy.go
Normal file
@ -0,0 +1,29 @@
|
||||
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, to *url.URL, from []string, funcs ...OptionFunc) (*store.Proxy, error) {
|
||||
request := admin.CreateProxyRequest{
|
||||
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
|
||||
}
|
26
internal/client/delete_proxy.go
Normal file
26
internal/client/delete_proxy.go
Normal 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, proxyID store.ProxyID, funcs ...OptionFunc) (store.ProxyID, error) {
|
||||
response := withResponse[admin.DeleteProxyResponse]()
|
||||
|
||||
path := fmt.Sprintf("/api/v1/proxies/%s", proxyID)
|
||||
|
||||
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.ProxyID, nil
|
||||
}
|
26
internal/client/get_proxy.go
Normal file
26
internal/client/get_proxy.go
Normal 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, proxyID store.ProxyID, funcs ...OptionFunc) (*store.Proxy, error) {
|
||||
response := withResponse[admin.GetProxyResponse]()
|
||||
|
||||
path := fmt.Sprintf("/api/v1/proxies/%s", proxyID)
|
||||
|
||||
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
|
||||
}
|
24
internal/client/options.go
Normal file
24
internal/client/options.go
Normal 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
|
||||
}
|
||||
}
|
75
internal/client/query_proxy.go
Normal file
75
internal/client/query_proxy.go
Normal 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
|
||||
IDs []store.ProxyID
|
||||
}
|
||||
|
||||
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 WithQueryProxyID(ids ...store.ProxyID) QueryProxyOptionFunc {
|
||||
return func(opts *QueryProxyOptions) {
|
||||
opts.IDs = ids
|
||||
}
|
||||
}
|
||||
|
||||
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.IDs != nil && len(options.IDs) > 0 {
|
||||
query.Set("ids", joinSlice(options.IDs))
|
||||
}
|
||||
|
||||
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
|
||||
}
|
29
internal/client/update_proxy.go
Normal file
29
internal/client/update_proxy.go
Normal file
@ -0,0 +1,29 @@
|
||||
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) UpdateProxy(ctx context.Context, to *url.URL, from []string, funcs ...OptionFunc) (*store.Proxy, error) {
|
||||
request := admin.CreateProxyRequest{
|
||||
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
|
||||
}
|
54
internal/command/admin/auth/create_token.go
Normal file
54
internal/command/admin/auth/create_token.go
Normal 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
|
||||
},
|
||||
}
|
||||
}
|
15
internal/command/admin/auth/root.go
Normal file
15
internal/command/admin/auth/root.go
Normal 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(),
|
||||
},
|
||||
}
|
||||
}
|
17
internal/command/admin/root.go
Normal file
17
internal/command/admin/root.go
Normal file
@ -0,0 +1,17 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"forge.cadoles.com/cadoles/bouncer/internal/command/admin/auth"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
func Root() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "admin",
|
||||
Usage: "Admin server related commands",
|
||||
Subcommands: []*cli.Command{
|
||||
RunCommand(),
|
||||
auth.Root(),
|
||||
},
|
||||
}
|
||||
}
|
54
internal/command/admin/run.go
Normal file
54
internal/command/admin/run.go
Normal 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
|
||||
},
|
||||
}
|
||||
}
|
91
internal/command/client/apierr/wrap.go
Normal file
91
internal/command/client/apierr/wrap.go
Normal 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)
|
||||
}
|
98
internal/command/client/flag/flag.go
Normal file
98
internal/command/client/flag/flag.go
Normal 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
|
||||
}
|
11
internal/command/client/flag/util.go
Normal file
11
internal/command/client/flag/util.go
Normal 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
|
||||
}
|
62
internal/command/client/proxy/create.go
Normal file
62
internal/command/client/proxy/create.go
Normal file
@ -0,0 +1,62 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"os"
|
||||
|
||||
"forge.cadoles.com/cadoles/bouncer/internal/client"
|
||||
"forge.cadoles.com/cadoles/bouncer/internal/command/client/apierr"
|
||||
clientFlag "forge.cadoles.com/cadoles/bouncer/internal/command/client/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: clientFlag.ComposeFlags(
|
||||
&cli.StringFlag{
|
||||
Name: "to",
|
||||
Usage: "SET `TO` as proxy destination url",
|
||||
Value: "",
|
||||
},
|
||||
&cli.StringSliceFlag{
|
||||
Name: "from",
|
||||
Usage: "Set `FROM` as 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))
|
||||
}
|
||||
|
||||
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, 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
|
||||
},
|
||||
}
|
||||
}
|
56
internal/command/client/proxy/delete.go
Normal file
56
internal/command/client/proxy/delete.go
Normal file
@ -0,0 +1,56 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"forge.cadoles.com/cadoles/bouncer/internal/client"
|
||||
"forge.cadoles.com/cadoles/bouncer/internal/command/client/apierr"
|
||||
clientFlag "forge.cadoles.com/cadoles/bouncer/internal/command/client/flag"
|
||||
proxyFlag "forge.cadoles.com/cadoles/bouncer/internal/command/client/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))
|
||||
}
|
||||
|
||||
proxyID, err := proxyFlag.AssertProxyID(ctx)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
client := client.New(baseFlags.ServerURL, client.WithToken(token))
|
||||
|
||||
proxyID, err = client.DeleteProxy(ctx.Context, proxyID)
|
||||
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 {
|
||||
ID store.ProxyID `json:"id"`
|
||||
}{
|
||||
ID: proxyID,
|
||||
}); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
33
internal/command/client/proxy/flag/flag.go
Normal file
33
internal/command/client/proxy/flag/flag.go
Normal file
@ -0,0 +1,33 @@
|
||||
package flag
|
||||
|
||||
import (
|
||||
clientFlag "forge.cadoles.com/cadoles/bouncer/internal/command/client/flag"
|
||||
"forge.cadoles.com/cadoles/bouncer/internal/store"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
func WithProxyFlags(flags ...cli.Flag) []cli.Flag {
|
||||
baseFlags := clientFlag.ComposeFlags(
|
||||
&cli.StringFlag{
|
||||
Name: "proxy-id",
|
||||
Aliases: []string{"p"},
|
||||
Usage: "use `PROXY_ID` as targeted proxy",
|
||||
Value: "",
|
||||
},
|
||||
)
|
||||
|
||||
flags = append(flags, baseFlags...)
|
||||
|
||||
return flags
|
||||
}
|
||||
|
||||
func AssertProxyID(ctx *cli.Context) (store.ProxyID, error) {
|
||||
rawProxyID := ctx.String("proxy-id")
|
||||
|
||||
if rawProxyID == "" {
|
||||
return "", errors.New("'proxy-id' cannot be empty")
|
||||
}
|
||||
|
||||
return store.ProxyID(rawProxyID), nil
|
||||
}
|
49
internal/command/client/proxy/get.go
Normal file
49
internal/command/client/proxy/get.go
Normal file
@ -0,0 +1,49 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"forge.cadoles.com/cadoles/bouncer/internal/client"
|
||||
"forge.cadoles.com/cadoles/bouncer/internal/command/client/apierr"
|
||||
clientFlag "forge.cadoles.com/cadoles/bouncer/internal/command/client/flag"
|
||||
proxyFlag "forge.cadoles.com/cadoles/bouncer/internal/command/client/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))
|
||||
}
|
||||
|
||||
proxyID, err := proxyFlag.AssertProxyID(ctx)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
client := client.New(baseFlags.ServerURL, client.WithToken(token))
|
||||
|
||||
proxy, err := client.GetProxy(ctx.Context, proxyID)
|
||||
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
|
||||
},
|
||||
}
|
||||
}
|
63
internal/command/client/proxy/query.go
Normal file
63
internal/command/client/proxy/query.go
Normal file
@ -0,0 +1,63 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"forge.cadoles.com/cadoles/bouncer/internal/client"
|
||||
"forge.cadoles.com/cadoles/bouncer/internal/command/client/apierr"
|
||||
clientFlag "forge.cadoles.com/cadoles/bouncer/internal/command/client/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)
|
||||
|
||||
rawIDs := ctx.StringSlice("ids")
|
||||
if rawIDs != nil {
|
||||
proxyIDs := func(ids []string) []store.ProxyID {
|
||||
agentIDs := make([]store.ProxyID, len(ids))
|
||||
for i, id := range ids {
|
||||
agentIDs[i] = store.ProxyID(id)
|
||||
}
|
||||
return agentIDs
|
||||
}(rawIDs)
|
||||
options = append(options, client.WithQueryProxyID(proxyIDs...))
|
||||
}
|
||||
|
||||
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
|
||||
},
|
||||
}
|
||||
}
|
18
internal/command/client/proxy/root.go
Normal file
18
internal/command/client/proxy/root.go
Normal file
@ -0,0 +1,18 @@
|
||||
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(),
|
||||
},
|
||||
}
|
||||
}
|
72
internal/command/client/proxy/update.go
Normal file
72
internal/command/client/proxy/update.go
Normal file
@ -0,0 +1,72 @@
|
||||
package proxy
|
||||
|
||||
// import (
|
||||
// "os"
|
||||
|
||||
// "forge.cadoles.com/Cadoles/emissary/internal/client"
|
||||
// agentFlag "forge.cadoles.com/Cadoles/emissary/internal/command/api/agent/flag"
|
||||
// "forge.cadoles.com/Cadoles/emissary/internal/command/api/apierr"
|
||||
// clientFlag "forge.cadoles.com/Cadoles/emissary/internal/command/api/flag"
|
||||
// "forge.cadoles.com/Cadoles/emissary/internal/format"
|
||||
// "github.com/pkg/errors"
|
||||
// "github.com/urfave/cli/v2"
|
||||
// )
|
||||
|
||||
// func UpdateCommand() *cli.Command {
|
||||
// return &cli.Command{
|
||||
// Name: "update",
|
||||
// Usage: "Updata agent",
|
||||
// Flags: agentFlag.WithAgentFlags(
|
||||
// &cli.IntFlag{
|
||||
// Name: "status",
|
||||
// Usage: "Set `STATUS` to selected agent",
|
||||
// Value: -1,
|
||||
// },
|
||||
// &cli.StringFlag{
|
||||
// Name: "label",
|
||||
// Usage: "Set `LABEL` to selected agent",
|
||||
// Value: "",
|
||||
// },
|
||||
// ),
|
||||
// Action: func(ctx *cli.Context) error {
|
||||
// baseFlags := clientFlag.GetBaseFlags(ctx)
|
||||
|
||||
// token, err := clientFlag.GetToken(baseFlags)
|
||||
// if err != nil {
|
||||
// return errors.WithStack(apierr.Wrap(err))
|
||||
// }
|
||||
|
||||
// agentID, err := agentFlag.AssertAgentID(ctx)
|
||||
// if err != nil {
|
||||
// return errors.WithStack(err)
|
||||
// }
|
||||
|
||||
// options := make([]client.UpdateAgentOptionFunc, 0)
|
||||
|
||||
// status := ctx.Int("status")
|
||||
// if status != -1 {
|
||||
// options = append(options, client.WithAgentStatus(status))
|
||||
// }
|
||||
|
||||
// label := ctx.String("label")
|
||||
// if label != "" {
|
||||
// options = append(options, client.WithAgentLabel(label))
|
||||
// }
|
||||
|
||||
// client := client.New(baseFlags.ServerURL, client.WithToken(token))
|
||||
|
||||
// agent, err := client.UpdateAgent(ctx.Context, agentID, options...)
|
||||
// if err != nil {
|
||||
// return errors.WithStack(apierr.Wrap(err))
|
||||
// }
|
||||
|
||||
// hints := agentHints(baseFlags.OutputMode)
|
||||
|
||||
// if err := format.Write(baseFlags.Format, os.Stdout, hints, agent); err != nil {
|
||||
// return errors.WithStack(err)
|
||||
// }
|
||||
|
||||
// return nil
|
||||
// },
|
||||
// }
|
||||
// }
|
31
internal/command/client/proxy/util.go
Normal file
31
internal/command/client/proxy/util.go
Normal file
@ -0,0 +1,31 @@
|
||||
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("ID", "ID", table.WithCompactModeMaxColumnWidth(8)),
|
||||
format.NewProp("CreatedAt", "CreatedAt", table.WithCompactModeMaxColumnWidth(20)),
|
||||
format.NewProp("UpdatedAt", "UpdatedAt", table.WithCompactModeMaxColumnWidth(20)),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func proxyHints(outputMode format.OutputMode) format.Hints {
|
||||
return format.Hints{
|
||||
OutputMode: outputMode,
|
||||
Props: []format.Prop{
|
||||
format.NewProp("ID", "ID", table.WithCompactModeMaxColumnWidth(8)),
|
||||
format.NewProp("From", "From"),
|
||||
format.NewProp("To", "To"),
|
||||
format.NewProp("Weight", "Weight"),
|
||||
format.NewProp("CreatedAt", "CreatedAt", table.WithCompactModeMaxColumnWidth(20)),
|
||||
format.NewProp("UpdatedAt", "UpdatedAt", table.WithCompactModeMaxColumnWidth(20)),
|
||||
},
|
||||
}
|
||||
}
|
16
internal/command/client/root.go
Normal file
16
internal/command/client/root.go
Normal file
@ -0,0 +1,16 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"forge.cadoles.com/cadoles/bouncer/internal/command/client/proxy"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
func Root() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "client",
|
||||
Usage: "Admin API related commands",
|
||||
Subcommands: []*cli.Command{
|
||||
proxy.Root(),
|
||||
},
|
||||
}
|
||||
}
|
7
internal/command/common/flags.go
Normal file
7
internal/command/common/flags.go
Normal file
@ -0,0 +1,7 @@
|
||||
package common
|
||||
|
||||
import "github.com/urfave/cli/v2"
|
||||
|
||||
func Flags() []cli.Flag {
|
||||
return []cli.Flag{}
|
||||
}
|
27
internal/command/common/load_config.go
Normal file
27
internal/command/common/load_config.go
Normal 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
|
||||
}
|
36
internal/command/config/dump.go
Normal file
36
internal/command/config/dump.go
Normal 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
|
||||
},
|
||||
}
|
||||
}
|
13
internal/command/config/root.go
Normal file
13
internal/command/config/root.go
Normal 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
107
internal/command/main.go
Normal 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)
|
||||
}
|
||||
}
|
15
internal/command/proxy/root.go
Normal file
15
internal/command/proxy/root.go
Normal 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(),
|
||||
},
|
||||
}
|
||||
}
|
54
internal/command/proxy/run.go
Normal file
54
internal/command/proxy/run.go
Normal file
@ -0,0 +1,54 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"forge.cadoles.com/cadoles/bouncer/internal/command/common"
|
||||
"forge.cadoles.com/cadoles/bouncer/internal/proxy"
|
||||
"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))
|
||||
|
||||
srv := proxy.NewServer(
|
||||
proxy.WithServerConfig(conf.Proxy),
|
||||
proxy.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
|
||||
},
|
||||
}
|
||||
}
|
27
internal/config/admin_server.go
Normal file
27
internal/config/admin_server.go
Normal 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
64
internal/config/config.go
Normal 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
|
||||
}
|
16
internal/config/config_test.go
Normal file
16
internal/config/config_test.go
Normal 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
20
internal/config/cors.go
Normal 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,
|
||||
}
|
||||
}
|
125
internal/config/environment.go
Normal file
125
internal/config/environment.go
Normal 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
13
internal/config/http.go
Normal 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
15
internal/config/logger.go
Normal 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),
|
||||
}
|
||||
}
|
11
internal/config/proxy_server.go
Normal file
11
internal/config/proxy_server.go
Normal 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
19
internal/config/redis.go
Normal 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
6
internal/config/testdata/config.yml
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
logger:
|
||||
level: 0
|
||||
format: human
|
||||
http:
|
||||
host: "0.0.0.0"
|
||||
port: 3000
|
38
internal/format/json/writer.go
Normal file
38
internal/format/json/writer.go
Normal 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
49
internal/format/prop.go
Normal 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
|
||||
}
|
46
internal/format/registry.go
Normal file
46
internal/format/registry.go
Normal 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
|
||||
}
|
61
internal/format/table/prop.go
Normal file
61
internal/format/table/prop.go
Normal 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)
|
||||
}
|
||||
}
|
80
internal/format/table/writer.go
Normal file
80
internal/format/table/writer.go
Normal 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{}
|
86
internal/format/table/writer_test.go
Normal file
86
internal/format/table/writer_test.go
Normal 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
19
internal/format/writer.go
Normal 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
|
||||
}
|
6
internal/imports/format/format_import.go
Normal file
6
internal/imports/format/format_import.go
Normal 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
140
internal/jwk/jwk.go
Normal 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
40
internal/jwk/jwk_test.go
Normal 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")
|
||||
}
|
||||
}
|
42
internal/proxy/init.go
Normal file
42
internal/proxy/init.go
Normal 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.initQueueRepository(ctx); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
if err := s.initProxyRepository(ctx); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) initQueueRepository(ctx context.Context) error {
|
||||
queueRepository, err := setup.NewQueueRepository(ctx, s.redisConfig)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
s.queueRepository = queueRepository
|
||||
|
||||
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
|
||||
}
|
90
internal/proxy/middleware/director/director.go
Normal file
90
internal/proxy/middleware/director/director.go
Normal file
@ -0,0 +1,90 @@
|
||||
package director
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"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 {
|
||||
repo store.ProxyRepository
|
||||
}
|
||||
|
||||
func (d *Director) rewriteRequest(r *http.Request) error {
|
||||
ctx := r.Context()
|
||||
|
||||
proxies, err := d.getProxies(ctx)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
var match *url.URL
|
||||
|
||||
MAIN:
|
||||
for _, p := range proxies {
|
||||
for _, from := range p.From {
|
||||
if matches := wildcard.Match(r.Host, from); !matches {
|
||||
continue
|
||||
}
|
||||
|
||||
match = p.To
|
||||
break MAIN
|
||||
}
|
||||
}
|
||||
|
||||
if match == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
r.URL.Host = match.Host
|
||||
r.URL.Scheme = match.Scheme
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Director) getProxies(ctx context.Context) ([]*store.Proxy, error) {
|
||||
headers, err := d.repo.QueryProxy(ctx)
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
proxies := make([]*store.Proxy, len(headers))
|
||||
|
||||
for i, h := range headers {
|
||||
proxy, err := d.repo.GetProxy(ctx, h.ID)
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
proxies[i] = proxy
|
||||
}
|
||||
|
||||
return proxies, nil
|
||||
}
|
||||
|
||||
func (d *Director) Middleware() proxy.Middleware {
|
||||
return func(next http.Handler) http.Handler {
|
||||
fn := func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := d.rewriteRequest(r); 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
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
return http.HandlerFunc(fn)
|
||||
}
|
||||
}
|
||||
|
||||
func New(repo store.ProxyRepository) *Director {
|
||||
return &Director{repo}
|
||||
}
|
31
internal/proxy/option.go
Normal file
31
internal/proxy/option.go
Normal file
@ -0,0 +1,31 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"forge.cadoles.com/cadoles/bouncer/internal/config"
|
||||
)
|
||||
|
||||
type Option struct {
|
||||
ServerConfig config.ProxyServerConfig
|
||||
RedisConfig config.RedisConfig
|
||||
}
|
||||
|
||||
type OptionFunc func(*Option)
|
||||
|
||||
func defaultOption() *Option {
|
||||
return &Option{
|
||||
ServerConfig: config.NewDefaultProxyServerConfig(),
|
||||
RedisConfig: config.NewDefaultRedisConfig(),
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
110
internal/proxy/server.go
Normal file
110
internal/proxy/server.go
Normal file
@ -0,0 +1,110 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
|
||||
"forge.cadoles.com/Cadoles/go-proxy"
|
||||
"forge.cadoles.com/cadoles/bouncer/internal/config"
|
||||
"forge.cadoles.com/cadoles/bouncer/internal/proxy/middleware/director"
|
||||
"forge.cadoles.com/cadoles/bouncer/internal/queue"
|
||||
"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
|
||||
queueRepository queue.Repository
|
||||
proxyRepository store.ProxyRepository
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
router.Use(middleware.Logger)
|
||||
|
||||
logger.Info(ctx, "http server listening")
|
||||
|
||||
queue := queue.New(s.queueRepository)
|
||||
director := director.New(s.proxyRepository)
|
||||
|
||||
handler := proxy.New(
|
||||
proxy.WithMiddlewares(
|
||||
director.Middleware(),
|
||||
queue.Middleware(),
|
||||
),
|
||||
)
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
9
internal/queue/options.go
Normal file
9
internal/queue/options.go
Normal file
@ -0,0 +1,9 @@
|
||||
package queue
|
||||
|
||||
type Options struct{}
|
||||
|
||||
type OptionFunc func(*Options)
|
||||
|
||||
func defaultOptions() *Options {
|
||||
return &Options{}
|
||||
}
|
32
internal/queue/queue.go
Normal file
32
internal/queue/queue.go
Normal file
@ -0,0 +1,32 @@
|
||||
package queue
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"forge.cadoles.com/Cadoles/go-proxy"
|
||||
)
|
||||
|
||||
type Queue struct {
|
||||
repository Repository
|
||||
}
|
||||
|
||||
func (q *Queue) Middleware() proxy.Middleware {
|
||||
return func(next http.Handler) http.Handler {
|
||||
fn := func(w http.ResponseWriter, r *http.Request) {
|
||||
next.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
return http.HandlerFunc(fn)
|
||||
}
|
||||
}
|
||||
|
||||
func New(repository Repository, funcs ...OptionFunc) *Queue {
|
||||
opts := defaultOptions()
|
||||
for _, fn := range funcs {
|
||||
fn(opts)
|
||||
}
|
||||
|
||||
return &Queue{
|
||||
repository: repository,
|
||||
}
|
||||
}
|
65
internal/queue/redis/repository.go
Normal file
65
internal/queue/redis/repository.go
Normal file
@ -0,0 +1,65 @@
|
||||
package redis
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"forge.cadoles.com/cadoles/bouncer/internal/queue"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
type Repository struct {
|
||||
client redis.UniversalClient
|
||||
}
|
||||
|
||||
// GetQueue implements queue.Repository
|
||||
func (*Repository) GetQueue(ctx context.Context, name string) (int, int, error) {
|
||||
panic("unimplemented")
|
||||
}
|
||||
|
||||
// CreateQueue implements queue.Repository
|
||||
func (*Repository) CreateQueue(ctx context.Context, name string, capacity int) error {
|
||||
panic("unimplemented")
|
||||
}
|
||||
|
||||
// CreateToken implements queue.Repository
|
||||
func (*Repository) CreateToken(ctx context.Context, name string) (string, int, error) {
|
||||
panic("unimplemented")
|
||||
}
|
||||
|
||||
// DeleteQueue implements queue.Repository
|
||||
func (*Repository) DeleteQueue(ctx context.Context, name string) error {
|
||||
panic("unimplemented")
|
||||
}
|
||||
|
||||
// GetTokenPosition implements queue.Repository
|
||||
func (*Repository) GetTokenPosition(ctx context.Context, name string, token string) (int, int, error) {
|
||||
panic("unimplemented")
|
||||
}
|
||||
|
||||
// RefreshQueue implements queue.Repository
|
||||
func (*Repository) RefreshQueue(ctx context.Context, name string) (int, int, error) {
|
||||
panic("unimplemented")
|
||||
}
|
||||
|
||||
// RemoveToken implements queue.Repository
|
||||
func (*Repository) RemoveToken(ctx context.Context, name string, token string) error {
|
||||
panic("unimplemented")
|
||||
}
|
||||
|
||||
// TouchToken implements queue.Repository
|
||||
func (*Repository) TouchToken(ctx context.Context, name string, token string) (int, error) {
|
||||
panic("unimplemented")
|
||||
}
|
||||
|
||||
// UpdateQueue implements queue.Repository
|
||||
func (*Repository) UpdateQueue(ctx context.Context, name string, capacity int) error {
|
||||
panic("unimplemented")
|
||||
}
|
||||
|
||||
func NewRepository(client redis.UniversalClient) *Repository {
|
||||
return &Repository{
|
||||
client: client,
|
||||
}
|
||||
}
|
||||
|
||||
var _ queue.Repository = &Repository{}
|
16
internal/queue/repository.go
Normal file
16
internal/queue/repository.go
Normal file
@ -0,0 +1,16 @@
|
||||
package queue
|
||||
|
||||
import "context"
|
||||
|
||||
type Repository interface {
|
||||
CreateQueue(ctx context.Context, name string, capacity int) error
|
||||
GetQueue(ctx context.Context, name string) (int, int, error)
|
||||
UpdateQueue(ctx context.Context, name string, capacity int) error
|
||||
DeleteQueue(ctx context.Context, name string) error
|
||||
RefreshQueue(ctx context.Context, name string) (int, int, error)
|
||||
|
||||
CreateToken(ctx context.Context, name string) (string, int, error)
|
||||
GetTokenPosition(ctx context.Context, name string, token string) (int, int, error)
|
||||
TouchToken(ctx context.Context, name string, token string) (int, error)
|
||||
RemoveToken(ctx context.Context, name string, token string) error
|
||||
}
|
19
internal/setup/proxy_repository.go
Normal file
19
internal/setup/proxy_repository.go
Normal file
@ -0,0 +1,19 @@
|
||||
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
|
||||
}
|
20
internal/setup/queue_repository.go
Normal file
20
internal/setup/queue_repository.go
Normal 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 NewQueueRepository(ctx context.Context, conf config.RedisConfig) (queue.Repository, error) {
|
||||
rdb := redis.NewUniversalClient(&redis.UniversalOptions{
|
||||
Addrs: conf.Adresses,
|
||||
MasterName: string(conf.Master),
|
||||
})
|
||||
|
||||
return queueRedis.NewRepository(rdb), nil
|
||||
}
|
5
internal/store/error.go
Normal file
5
internal/store/error.go
Normal file
@ -0,0 +1,5 @@
|
||||
package store
|
||||
|
||||
import "errors"
|
||||
|
||||
var ErrNotFound = errors.New("not found")
|
19
internal/store/id.go
Normal file
19
internal/store/id.go
Normal file
@ -0,0 +1,19 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"github.com/google/uuid"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
func NewID() string {
|
||||
return uuid.NewString()
|
||||
}
|
||||
|
||||
func ParseID[T ~string](raw string) (T, error) {
|
||||
uuid, err := uuid.Parse(raw)
|
||||
if err != nil {
|
||||
return *new(T), errors.WithStack(err)
|
||||
}
|
||||
|
||||
return T(uuid.String()), nil
|
||||
}
|
27
internal/store/proxy.go
Normal file
27
internal/store/proxy.go
Normal file
@ -0,0 +1,27 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
type ProxyID string
|
||||
|
||||
func NewProxyID() ProxyID {
|
||||
return ProxyID(NewID())
|
||||
}
|
||||
|
||||
var ParseProxyID = ParseID[ProxyID]
|
||||
|
||||
type ProxyHeader struct {
|
||||
ID ProxyID
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type Proxy struct {
|
||||
ProxyHeader
|
||||
To *url.URL
|
||||
From []string
|
||||
Weight int
|
||||
}
|
73
internal/store/proxy_repository.go
Normal file
73
internal/store/proxy_repository.go
Normal file
@ -0,0 +1,73 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
type ProxyRepository interface {
|
||||
CreateProxy(ctx context.Context, to *url.URL, from ...string) (*Proxy, error)
|
||||
UpdateProxy(ctx context.Context, id ProxyID, funcs ...UpdateProxyOptionFunc) (*Proxy, error)
|
||||
QueryProxy(ctx context.Context, funcs ...QueryProxyOptionFunc) ([]*ProxyHeader, error)
|
||||
GetProxy(ctx context.Context, id ProxyID) (*Proxy, error)
|
||||
DeleteProxy(ctx context.Context, id ProxyID) error
|
||||
}
|
||||
|
||||
type UpdateProxyOptionFunc func(*UpdateProxyOptions)
|
||||
|
||||
type UpdateProxyOptions struct {
|
||||
To *url.URL
|
||||
From []string
|
||||
}
|
||||
|
||||
func WithProxyUpdateTo(to *url.URL) UpdateProxyOptionFunc {
|
||||
return func(o *UpdateProxyOptions) {
|
||||
o.To = to
|
||||
}
|
||||
}
|
||||
|
||||
func WithProxyUpdateFrom(from ...string) UpdateProxyOptionFunc {
|
||||
return func(o *UpdateProxyOptions) {
|
||||
o.From = from
|
||||
}
|
||||
}
|
||||
|
||||
type QueryProxyOptionFunc func(*QueryProxyOptions)
|
||||
|
||||
type QueryProxyOptions struct {
|
||||
To *url.URL
|
||||
IDs []ProxyID
|
||||
From []string
|
||||
Offset *int
|
||||
Limit *int
|
||||
}
|
||||
|
||||
func WithProxyQueryOffset(offset int) QueryProxyOptionFunc {
|
||||
return func(o *QueryProxyOptions) {
|
||||
o.Offset = &offset
|
||||
}
|
||||
}
|
||||
|
||||
func WithProxyQueryLimit(limit int) QueryProxyOptionFunc {
|
||||
return func(o *QueryProxyOptions) {
|
||||
o.Limit = &limit
|
||||
}
|
||||
}
|
||||
|
||||
func WithProxyQueryTo(to *url.URL) QueryProxyOptionFunc {
|
||||
return func(o *QueryProxyOptions) {
|
||||
o.To = to
|
||||
}
|
||||
}
|
||||
|
||||
func WithProxyQueryFrom(from ...string) QueryProxyOptionFunc {
|
||||
return func(o *QueryProxyOptions) {
|
||||
o.From = from
|
||||
}
|
||||
}
|
||||
|
||||
func WithProxyQueryIDs(ids ...ProxyID) QueryProxyOptionFunc {
|
||||
return func(o *QueryProxyOptions) {
|
||||
o.IDs = ids
|
||||
}
|
||||
}
|
253
internal/store/redis/proxy_repository.go
Normal file
253
internal/store/redis/proxy_repository.go
Normal file
@ -0,0 +1,253 @@
|
||||
package redis
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"forge.cadoles.com/cadoles/bouncer/internal/store"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
const (
|
||||
keyID = "id"
|
||||
keyFrom = "from"
|
||||
keyTo = "to"
|
||||
keyUpdatedAt = "updated_at"
|
||||
keyCreatedAt = "created_at"
|
||||
keyWeight = "weight"
|
||||
keyPrefixProxy = "proxy:"
|
||||
)
|
||||
|
||||
type ProxyRepository struct {
|
||||
client redis.UniversalClient
|
||||
}
|
||||
|
||||
// GetProxy implements store.ProxyRepository
|
||||
func (r *ProxyRepository) GetProxy(ctx context.Context, id store.ProxyID) (*store.Proxy, error) {
|
||||
var proxy store.Proxy
|
||||
|
||||
key := proxyKey(id)
|
||||
|
||||
cmd := r.client.HMGet(ctx, key, keyFrom, keyTo, keyWeight, keyCreatedAt, keyUpdatedAt)
|
||||
|
||||
values, err := cmd.Result()
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
if allNilValues(values) {
|
||||
return nil, errors.WithStack(store.ErrNotFound)
|
||||
}
|
||||
|
||||
proxy.ID = id
|
||||
|
||||
from, err := unwrap[[]string](values[0])
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
proxy.From = from
|
||||
|
||||
rawTo, ok := values[1].(string)
|
||||
if !ok {
|
||||
return nil, errors.Errorf("unexpected 'to' value of type '%T'", values[1])
|
||||
}
|
||||
|
||||
to, err := url.Parse(rawTo)
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
proxy.To = to
|
||||
|
||||
weight, err := unwrap[int](values[2])
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
proxy.Weight = weight
|
||||
|
||||
createdAt, err := unwrap[time.Time](values[3])
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
proxy.CreatedAt = createdAt
|
||||
|
||||
updatedAt, err := unwrap[time.Time](values[4])
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
proxy.UpdatedAt = updatedAt
|
||||
|
||||
return &proxy, nil
|
||||
}
|
||||
|
||||
// CreateProxy implements store.ProxyRepository
|
||||
func (r *ProxyRepository) CreateProxy(ctx context.Context, to *url.URL, from ...string) (*store.Proxy, error) {
|
||||
id := store.NewProxyID()
|
||||
now := time.Now().UTC()
|
||||
|
||||
_, err := r.client.Pipelined(ctx, func(p redis.Pipeliner) error {
|
||||
key := proxyKey(id)
|
||||
|
||||
p.HMSet(ctx, key, keyID, string(id))
|
||||
p.HMSet(ctx, key, keyFrom, wrap(from))
|
||||
p.HMSet(ctx, key, keyTo, to.String())
|
||||
p.HMSet(ctx, key, keyWeight, wrap(0))
|
||||
p.HMSet(ctx, key, keyCreatedAt, wrap(now))
|
||||
p.HMSet(ctx, key, keyUpdatedAt, wrap(now))
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
return &store.Proxy{
|
||||
ProxyHeader: store.ProxyHeader{
|
||||
ID: id,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
To: to,
|
||||
From: from,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// DeleteProxy implements store.ProxyRepository
|
||||
func (r *ProxyRepository) DeleteProxy(ctx context.Context, id store.ProxyID) error {
|
||||
key := proxyKey(id)
|
||||
|
||||
if cmd := r.client.Del(ctx, key); cmd.Err() != nil {
|
||||
return errors.WithStack(cmd.Err())
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// QueryProxy implements store.ProxyRepository
|
||||
func (r *ProxyRepository) QueryProxy(ctx context.Context, funcs ...store.QueryProxyOptionFunc) ([]*store.ProxyHeader, error) {
|
||||
iter := r.client.Scan(ctx, 0, keyPrefixProxy+"*", 0).Iterator()
|
||||
|
||||
headers := make([]*store.ProxyHeader, 0)
|
||||
|
||||
for iter.Next(ctx) {
|
||||
key := iter.Val()
|
||||
|
||||
cmd := r.client.HMGet(ctx, key, keyID, keyCreatedAt, keyUpdatedAt)
|
||||
|
||||
values, err := cmd.Result()
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
if allNilValues(values) {
|
||||
continue
|
||||
}
|
||||
|
||||
id, ok := values[0].(string)
|
||||
if !ok {
|
||||
return nil, errors.Errorf("unexpected 'id' field value for key '%s': '%s'", key, values[0])
|
||||
}
|
||||
|
||||
createdAt, err := unwrap[time.Time](values[1])
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
updatedAt, err := unwrap[time.Time](values[2])
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
h := &store.ProxyHeader{
|
||||
ID: store.ProxyID(id),
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: updatedAt,
|
||||
}
|
||||
|
||||
headers = append(headers, h)
|
||||
}
|
||||
|
||||
if err := iter.Err(); err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
return headers, nil
|
||||
}
|
||||
|
||||
// UpdateProxy implements store.ProxyRepository
|
||||
func (*ProxyRepository) UpdateProxy(ctx context.Context, id store.ProxyID, funcs ...store.UpdateProxyOptionFunc) (*store.Proxy, error) {
|
||||
panic("unimplemented")
|
||||
}
|
||||
|
||||
func NewProxyRepository(client redis.UniversalClient) *ProxyRepository {
|
||||
return &ProxyRepository{
|
||||
client: client,
|
||||
}
|
||||
}
|
||||
|
||||
var _ store.ProxyRepository = &ProxyRepository{}
|
||||
|
||||
func proxyKey(id store.ProxyID) string {
|
||||
return keyPrefixProxy + string(id)
|
||||
}
|
||||
|
||||
type jsonWrapper[T any] struct {
|
||||
value T
|
||||
}
|
||||
|
||||
func (w *jsonWrapper[T]) MarshalBinary() ([]byte, error) {
|
||||
data, err := json.Marshal(w.value)
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (w *jsonWrapper[T]) UnmarshalBinary(data []byte) error {
|
||||
if err := json.Unmarshal(data, &w.value); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *jsonWrapper[T]) Value() T {
|
||||
return w.value
|
||||
}
|
||||
|
||||
func wrap[T any](v T) *jsonWrapper[T] {
|
||||
return &jsonWrapper[T]{v}
|
||||
}
|
||||
|
||||
func unwrap[T any](v any) (T, error) {
|
||||
str, ok := v.(string)
|
||||
if !ok {
|
||||
return *new(T), errors.Errorf("could not unwrap value of type '%T'", v)
|
||||
}
|
||||
|
||||
u := new(T)
|
||||
|
||||
if err := json.Unmarshal([]byte(str), u); err != nil {
|
||||
return *new(T), errors.WithStack(err)
|
||||
}
|
||||
|
||||
return *u, nil
|
||||
}
|
||||
|
||||
func allNilValues(values []any) bool {
|
||||
for _, v := range values {
|
||||
if v != nil {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
64
internal/store/redis/proxy_repository_test.go
Normal file
64
internal/store/redis/proxy_repository_test.go
Normal file
@ -0,0 +1,64 @@
|
||||
package redis
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"forge.cadoles.com/cadoles/bouncer/internal/store/testsuite"
|
||||
"github.com/ory/dockertest/v3"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
var client redis.UniversalClient
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
// uses a sensible default on windows (tcp/http) and linux/osx (socket)
|
||||
pool, err := dockertest.NewPool("")
|
||||
if err != nil {
|
||||
log.Fatalf("%+v", errors.WithStack(err))
|
||||
}
|
||||
|
||||
// uses pool to try to connect to Docker
|
||||
err = pool.Client.Ping()
|
||||
if err != nil {
|
||||
log.Fatalf("%+v", errors.WithStack(err))
|
||||
}
|
||||
|
||||
// pulls an image, creates a container based on it and runs it
|
||||
resource, err := pool.Run("redis", "alpine3.17", []string{})
|
||||
if err != nil {
|
||||
log.Fatalf("%+v", errors.WithStack(err))
|
||||
}
|
||||
|
||||
if err := pool.Retry(func() error {
|
||||
client = redis.NewUniversalClient(&redis.UniversalOptions{
|
||||
Addrs: []string{resource.GetHostPort("6379/tcp")},
|
||||
})
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
if cmd := client.Ping(ctx); cmd.Err() != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}); err != nil {
|
||||
log.Fatalf("%+v", errors.WithStack(err))
|
||||
}
|
||||
|
||||
code := m.Run()
|
||||
|
||||
if err := pool.Purge(resource); err != nil {
|
||||
log.Fatalf("%+v", errors.WithStack(err))
|
||||
}
|
||||
|
||||
os.Exit(code)
|
||||
}
|
||||
|
||||
func TestProxyRepository(t *testing.T) {
|
||||
repository := NewProxyRepository(client)
|
||||
testsuite.TestProxyRepository(t, repository)
|
||||
}
|
191
internal/store/testsuite/proxy_repository.go
Normal file
191
internal/store/testsuite/proxy_repository.go
Normal file
@ -0,0 +1,191 @@
|
||||
package testsuite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"forge.cadoles.com/cadoles/bouncer/internal/store"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
type proxyRepositoryTestCase struct {
|
||||
Name string
|
||||
Do func(repo store.ProxyRepository) error
|
||||
}
|
||||
|
||||
var proxyRepositoryTestCases = []proxyRepositoryTestCase{
|
||||
{
|
||||
Name: "Create proxy",
|
||||
Do: func(repo store.ProxyRepository) error {
|
||||
ctx := context.Background()
|
||||
|
||||
url, err := url.Parse("http://example.com")
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
proxy, err := repo.CreateProxy(ctx, url, "*:*")
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
if proxy == nil {
|
||||
return errors.Errorf("proxy should not be nil")
|
||||
}
|
||||
|
||||
if proxy.ID == "" {
|
||||
return errors.Errorf("proxy.ID should not be empty")
|
||||
}
|
||||
|
||||
if proxy.To == nil {
|
||||
return errors.Errorf("proxy.To should not be nil")
|
||||
}
|
||||
|
||||
if e, g := url.String(), proxy.To.String(); e != g {
|
||||
return errors.Errorf("proxy.URL.String(): expected '%v', got '%v'", url.String(), proxy.To.String())
|
||||
}
|
||||
|
||||
if proxy.CreatedAt.IsZero() {
|
||||
return errors.Errorf("proxy.CreatedAt should not be zero value")
|
||||
}
|
||||
|
||||
if proxy.UpdatedAt.IsZero() {
|
||||
return errors.Errorf("proxy.UpdatedAt should not be zero value")
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "Create then get proxy",
|
||||
Do: func(repo store.ProxyRepository) error {
|
||||
ctx := context.Background()
|
||||
|
||||
url, err := url.Parse("http://example.com")
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
createdProxy, err := repo.CreateProxy(ctx, url, "127.0.0.1:*", "localhost:*")
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
foundProxy, err := repo.GetProxy(ctx, createdProxy.ID)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
if e, g := createdProxy.ID, foundProxy.ID; e != g {
|
||||
return errors.Errorf("foundProxy.ID: expected '%v', got '%v'", createdProxy.ID, foundProxy.ID)
|
||||
}
|
||||
|
||||
if e, g := createdProxy.From, foundProxy.From; !reflect.DeepEqual(e, g) {
|
||||
return errors.Errorf("foundProxy.From: expected '%v', got '%v'", createdProxy.From, foundProxy.From)
|
||||
}
|
||||
|
||||
if e, g := createdProxy.To.String(), foundProxy.To.String(); e != g {
|
||||
return errors.Errorf("foundProxy.To: expected '%v', got '%v'", createdProxy.To, foundProxy.To)
|
||||
}
|
||||
|
||||
if e, g := createdProxy.CreatedAt, foundProxy.CreatedAt; e != g {
|
||||
return errors.Errorf("foundProxy.CreatedAt: expected '%v', got '%v'", createdProxy.CreatedAt, foundProxy.CreatedAt)
|
||||
}
|
||||
|
||||
if e, g := createdProxy.UpdatedAt, foundProxy.UpdatedAt; e != g {
|
||||
return errors.Errorf("foundProxy.UpdatedAt: expected '%v', got '%v'", createdProxy.UpdatedAt, foundProxy.UpdatedAt)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "Create then delete proxy",
|
||||
Do: func(repo store.ProxyRepository) error {
|
||||
ctx := context.Background()
|
||||
|
||||
url, err := url.Parse("http://example.com")
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
createdProxy, err := repo.CreateProxy(ctx, url, "127.0.0.1:*", "localhost:*")
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
if err := repo.DeleteProxy(ctx, createdProxy.ID); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
foundProxy, err := repo.GetProxy(ctx, createdProxy.ID)
|
||||
if err == nil {
|
||||
return errors.New("err should not be nil")
|
||||
}
|
||||
|
||||
if !errors.Is(err, store.ErrNotFound) {
|
||||
return errors.Errorf("err should be store.ErrNotFound, got '%+v'", err)
|
||||
}
|
||||
|
||||
if foundProxy != nil {
|
||||
return errors.Errorf("foundProxy should be nil, got '%v'", foundProxy)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "Create then query",
|
||||
Do: func(repo store.ProxyRepository) error {
|
||||
ctx := context.Background()
|
||||
|
||||
url, err := url.Parse("http://example.com")
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
createdProxy, err := repo.CreateProxy(ctx, url, "127.0.0.1:*", "localhost:*")
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
headers, err := repo.QueryProxy(ctx)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
if len(headers) < 1 {
|
||||
return errors.Errorf("len(headers): expected value > 1, got '%v'", len(headers))
|
||||
}
|
||||
|
||||
found := false
|
||||
|
||||
for _, h := range headers {
|
||||
if h.ID == createdProxy.ID {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
return errors.New("could not find created proxy in query results")
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
func TestProxyRepository(t *testing.T, repo store.ProxyRepository) {
|
||||
for _, tc := range proxyRepositoryTestCases {
|
||||
func(tc proxyRepositoryTestCase) {
|
||||
t.Run(tc.Name, func(t *testing.T) {
|
||||
if err := tc.Do(repo); err != nil {
|
||||
t.Fatalf("%+v", errors.WithStack(err))
|
||||
}
|
||||
})
|
||||
}(tc)
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user