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:
127
internal/admin/authorization.go
Normal file
127
internal/admin/authorization.go
Normal file
@ -0,0 +1,127 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"forge.cadoles.com/cadoles/bouncer/internal/auth"
|
||||
"forge.cadoles.com/cadoles/bouncer/internal/auth/thirdparty"
|
||||
"github.com/pkg/errors"
|
||||
"gitlab.com/wpetit/goweb/api"
|
||||
"gitlab.com/wpetit/goweb/logger"
|
||||
)
|
||||
|
||||
var ErrCodeForbidden api.ErrorCode = "forbidden"
|
||||
|
||||
func assertGlobalReadAccess(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 *thirdparty.User:
|
||||
role := user.Role()
|
||||
if role == thirdparty.RoleReader || role == thirdparty.RoleWriter {
|
||||
h.ServeHTTP(w, r)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
default:
|
||||
logUnexpectedUserType(r.Context(), reqUser)
|
||||
}
|
||||
|
||||
forbidden(w, r)
|
||||
}
|
||||
|
||||
return http.HandlerFunc(fn)
|
||||
}
|
||||
|
||||
func assertInboundWriteAccess(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 *thirdparty.User:
|
||||
role := user.Role()
|
||||
if role == thirdparty.RoleWriter {
|
||||
h.ServeHTTP(w, r)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
default:
|
||||
logUnexpectedUserType(r.Context(), reqUser)
|
||||
}
|
||||
|
||||
forbidden(w, r)
|
||||
}
|
||||
|
||||
return http.HandlerFunc(fn)
|
||||
}
|
||||
|
||||
func assertInboundReadAccess(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 *thirdparty.User:
|
||||
role := user.Role()
|
||||
if role == thirdparty.RoleReader || role == thirdparty.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)),
|
||||
)
|
||||
}
|
19
internal/admin/init.go
Normal file
19
internal/admin/init.go
Normal file
@ -0,0 +1,19 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"forge.cadoles.com/cadoles/bouncer/internal/setup"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
func (s *Server) initRepositories(ctx context.Context) error {
|
||||
proxyRepo, err := setup.NewProxyRepository(ctx, s.databaseConfig)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
s.repo = proxyRepo
|
||||
|
||||
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
|
||||
DatabaseConfig config.DatabaseConfig
|
||||
}
|
||||
|
||||
type OptionFunc func(*Option)
|
||||
|
||||
func defaultOption() *Option {
|
||||
return &Option{
|
||||
ServerConfig: config.NewDefaultAdminServerConfig(),
|
||||
DatabaseConfig: config.NewDefaultDatabaseConfig(),
|
||||
}
|
||||
}
|
||||
|
||||
func WithServerConfig(conf config.AdminServerConfig) OptionFunc {
|
||||
return func(opt *Option) {
|
||||
opt.ServerConfig = conf
|
||||
}
|
||||
}
|
||||
|
||||
func WithDatabaseConfig(conf config.DatabaseConfig) OptionFunc {
|
||||
return func(opt *Option) {
|
||||
opt.DatabaseConfig = conf
|
||||
}
|
||||
}
|
138
internal/admin/server.go
Normal file
138
internal/admin/server.go
Normal file
@ -0,0 +1,138 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
|
||||
"forge.cadoles.com/cadoles/bouncer/internal/auth"
|
||||
"forge.cadoles.com/cadoles/bouncer/internal/auth/thirdparty"
|
||||
"forge.cadoles.com/cadoles/bouncer/internal/config"
|
||||
"forge.cadoles.com/cadoles/bouncer/internal/datastore"
|
||||
"forge.cadoles.com/cadoles/bouncer/internal/jwk"
|
||||
"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
|
||||
databaseConfig config.DatabaseConfig
|
||||
repo datastore.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(
|
||||
thirdparty.NewAuthenticator(keys, string(s.serverConfig.Auth.Issuer), thirdparty.DefaultAcceptableSkew),
|
||||
))
|
||||
|
||||
r.Route("/inbounds", func(r chi.Router) {
|
||||
// r.With(assertGlobalReadAccess).Get("/", s.queryInbounds)
|
||||
})
|
||||
|
||||
r.Route("/outbounds", func(r chi.Router) {
|
||||
// r.With(assertGlobalReadAccess).Get("/", s.queryOutbounds)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
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,
|
||||
databaseConfig: opt.DatabaseConfig,
|
||||
}
|
||||
}
|
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)
|
||||
}
|
||||
}
|
72
internal/auth/thirdparty/authenticator.go
vendored
Normal file
72
internal/auth/thirdparty/authenticator.go
vendored
Normal file
@ -0,0 +1,72 @@
|
||||
package thirdparty
|
||||
|
||||
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/thirdparty/jwt.go
vendored
Normal file
62
internal/auth/thirdparty/jwt.go
vendored
Normal file
@ -0,0 +1,62 @@
|
||||
package thirdparty
|
||||
|
||||
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/thirdparty/user.go
vendored
Normal file
32
internal/auth/thirdparty/user.go
vendored
Normal file
@ -0,0 +1,32 @@
|
||||
package thirdparty
|
||||
|
||||
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{}
|
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,
|
||||
}
|
||||
}
|
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
|
||||
}
|
||||
}
|
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/thirdparty"
|
||||
"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)", []thirdparty.Role{thirdparty.RoleReader, thirdparty.RoleWriter}),
|
||||
Value: string(thirdparty.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 := thirdparty.GenerateToken(ctx.Context, key, string(conf.Admin.Auth.Issuer), subject, thirdparty.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.WithDatabaseConfig(conf.Database),
|
||||
)
|
||||
|
||||
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/api/apierr/wrap.go
Normal file
91
internal/command/api/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)
|
||||
}
|
96
internal/command/api/flag/flag.go
Normal file
96
internal/command/api/flag/flag.go
Normal file
@ -0,0 +1,96 @@
|
||||
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:3000",
|
||||
},
|
||||
&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"},
|
||||
Usage: "use `TOKEN` as authentication token",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "token-file",
|
||||
Usage: "use `TOKEN_FILE` as file containing the authentication token",
|
||||
Value: ".emissary-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/api/flag/util.go
Normal file
11
internal/command/api/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
|
||||
}
|
34
internal/command/api/inbound/flag/flag.go
Normal file
34
internal/command/api/inbound/flag/flag.go
Normal file
@ -0,0 +1,34 @@
|
||||
package flag
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
clientFlag "forge.cadoles.com/cadoles/bouncer/internal/command/api/flag"
|
||||
"forge.cadoles.com/cadoles/bouncer/internal/datastore"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
func WithInboundFlags(flags ...cli.Flag) []cli.Flag {
|
||||
baseFlags := clientFlag.ComposeFlags(
|
||||
&cli.StringFlag{
|
||||
Name: "inbound-id",
|
||||
Aliases: []string{"in"},
|
||||
Usage: "use `INBOUND_ID` as selected inbound",
|
||||
Value: "",
|
||||
},
|
||||
)
|
||||
|
||||
flags = append(flags, baseFlags...)
|
||||
|
||||
return flags
|
||||
}
|
||||
|
||||
func AssertInboundID(ctx *cli.Context) (datastore.InboundID, error) {
|
||||
rawInboundID := ctx.String("inbound-id")
|
||||
|
||||
if rawInboundID == "" {
|
||||
return "", errors.New("flag 'inbound-id' is required")
|
||||
}
|
||||
|
||||
return datastore.InboundID(rawInboundID), nil
|
||||
}
|
13
internal/command/api/inbound/root.go
Normal file
13
internal/command/api/inbound/root.go
Normal file
@ -0,0 +1,13 @@
|
||||
package inbound
|
||||
|
||||
import (
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
func Root() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "agent",
|
||||
Usage: "Inbounds related commands",
|
||||
Subcommands: []*cli.Command{},
|
||||
}
|
||||
}
|
17
internal/command/api/inbound/util.go
Normal file
17
internal/command/api/inbound/util.go
Normal file
@ -0,0 +1,17 @@
|
||||
package inbound
|
||||
|
||||
import "forge.cadoles.com/cadoles/bouncer/internal/format"
|
||||
|
||||
func agentHints(outputMode format.OutputMode) format.Hints {
|
||||
return format.Hints{
|
||||
OutputMode: outputMode,
|
||||
Props: []format.Prop{
|
||||
format.NewProp("ID", "ID"),
|
||||
format.NewProp("Label", "Label"),
|
||||
format.NewProp("Thumbprint", "Thumbprint"),
|
||||
format.NewProp("Status", "Status"),
|
||||
format.NewProp("ContactedAt", "ContactedAt"),
|
||||
format.NewProp("UpdatedAt", "UpdatedAt"),
|
||||
},
|
||||
}
|
||||
}
|
16
internal/command/api/root.go
Normal file
16
internal/command/api/root.go
Normal file
@ -0,0 +1,16 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"forge.cadoles.com/cadoles/bouncer/internal/command/api/inbound"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
func Root() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "api",
|
||||
Usage: "API related commands",
|
||||
Subcommands: []*cli.Command{
|
||||
inbound.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(),
|
||||
},
|
||||
}
|
||||
}
|
105
internal/command/database/migrate.go
Normal file
105
internal/command/database/migrate.go
Normal file
@ -0,0 +1,105 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"forge.cadoles.com/cadoles/bouncer/internal/command/common"
|
||||
"forge.cadoles.com/cadoles/bouncer/internal/migrate"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/urfave/cli/v2"
|
||||
"gitlab.com/wpetit/goweb/logger"
|
||||
)
|
||||
|
||||
const (
|
||||
MigrateVersionUp = "up"
|
||||
MigrateVersionLatest = "latest"
|
||||
MigrateVersionDown = "down"
|
||||
)
|
||||
|
||||
func MigrateCommand() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "migrate",
|
||||
Usage: "Migrate database schema to latest version",
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "target",
|
||||
Usage: "Migration target, default to latest",
|
||||
Value: "latest",
|
||||
},
|
||||
&cli.IntFlag{
|
||||
Name: "force",
|
||||
Usage: "Force migration to version",
|
||||
Value: -1,
|
||||
},
|
||||
},
|
||||
Action: func(ctx *cli.Context) error {
|
||||
conf, err := common.LoadConfig(ctx)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "Could not load configuration")
|
||||
}
|
||||
|
||||
driver := string(conf.Database.Driver)
|
||||
dsn := string(conf.Database.DSN)
|
||||
|
||||
migr, err := migrate.New("migrations", driver, dsn)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
version, dirty, err := migr.Version()
|
||||
if err != nil && !errors.Is(err, migrate.ErrNilVersion) {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
logger.Info(
|
||||
ctx.Context, "current database shema",
|
||||
logger.F("version", version),
|
||||
logger.F("dirty", dirty),
|
||||
)
|
||||
|
||||
target := ctx.String("target")
|
||||
force := ctx.Int("force")
|
||||
|
||||
if force != -1 {
|
||||
logger.Info(ctx.Context, "forcing database schema version", logger.F("version", force))
|
||||
|
||||
if err := migr.Force(force); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
switch target {
|
||||
case "":
|
||||
fallthrough
|
||||
case MigrateVersionLatest:
|
||||
err = migr.Up()
|
||||
case MigrateVersionDown:
|
||||
err = migr.Steps(-1)
|
||||
case MigrateVersionUp:
|
||||
err = migr.Steps(1)
|
||||
default:
|
||||
return errors.Errorf(
|
||||
"unknown migration target: '%s', available: '%s' (default), '%s' or '%s'",
|
||||
target, MigrateVersionLatest, MigrateVersionUp, MigrateVersionDown,
|
||||
)
|
||||
}
|
||||
|
||||
if err != nil && !errors.Is(err, migrate.ErrNoChange) {
|
||||
return errors.Wrap(err, "could not apply migration")
|
||||
}
|
||||
|
||||
version, dirty, err = migr.Version()
|
||||
if err != nil && !errors.Is(err, migrate.ErrNilVersion) {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
logger.Info(
|
||||
ctx.Context, "database shema after migration",
|
||||
logger.F("version", version),
|
||||
logger.F("dirty", dirty),
|
||||
)
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
48
internal/command/database/ping.go
Normal file
48
internal/command/database/ping.go
Normal file
@ -0,0 +1,48 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"forge.cadoles.com/cadoles/bouncer/internal/command/common"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/urfave/cli/v2"
|
||||
"gitlab.com/wpetit/goweb/logger"
|
||||
)
|
||||
|
||||
func PingCommand() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "ping",
|
||||
Usage: "Test database connectivity",
|
||||
Action: func(ctx *cli.Context) error {
|
||||
conf, err := common.LoadConfig(ctx)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "Could not load configuration")
|
||||
}
|
||||
|
||||
logger.Info(ctx.Context, "connecting to database", logger.F("dsn", conf.Database.DSN))
|
||||
|
||||
driver := string(conf.Database.Driver)
|
||||
dsn := string(conf.Database.DSN)
|
||||
|
||||
db, err := sql.Open(driver, dsn)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if err := db.Close(); err != nil {
|
||||
logger.Error(ctx.Context, "error while closing database connection", logger.E(errors.WithStack(err)))
|
||||
}
|
||||
}()
|
||||
|
||||
if err := db.PingContext(ctx.Context); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
logger.Info(ctx.Context, "connection succeeded", logger.F("dsn", conf.Database.DSN))
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
38
internal/command/database/reset.go
Normal file
38
internal/command/database/reset.go
Normal file
@ -0,0 +1,38 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"forge.cadoles.com/cadoles/bouncer/internal/command/common"
|
||||
"forge.cadoles.com/cadoles/bouncer/internal/migrate"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/urfave/cli/v2"
|
||||
"gitlab.com/wpetit/goweb/logger"
|
||||
)
|
||||
|
||||
func ResetCommand() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "reset",
|
||||
Usage: "Reset database",
|
||||
Action: func(ctx *cli.Context) error {
|
||||
conf, err := common.LoadConfig(ctx)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "Could not load configuration")
|
||||
}
|
||||
|
||||
driver := string(conf.Database.Driver)
|
||||
dsn := string(conf.Database.DSN)
|
||||
|
||||
migr, err := migrate.New("migrations", driver, dsn)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
if err := migr.Drop(); err != nil {
|
||||
return errors.Wrap(err, "could not drop tables")
|
||||
}
|
||||
|
||||
logger.Info(ctx.Context, "database schema reinitialized")
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
15
internal/command/database/root.go
Normal file
15
internal/command/database/root.go
Normal file
@ -0,0 +1,15 @@
|
||||
package database
|
||||
|
||||
import "github.com/urfave/cli/v2"
|
||||
|
||||
func Root() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "database",
|
||||
Usage: "Database related commands",
|
||||
Subcommands: []*cli.Command{
|
||||
MigrateCommand(),
|
||||
PingCommand(),
|
||||
ResetCommand(),
|
||||
},
|
||||
}
|
||||
}
|
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{"EMISSARY_DEBUG"},
|
||||
Value: false,
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "config",
|
||||
Aliases: []string{"c"},
|
||||
EnvVars: []string{"EMISSARY_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.WithDatabaseConfig(conf.Database),
|
||||
)
|
||||
|
||||
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"`
|
||||
Database DatabaseConfig `yaml:"database"`
|
||||
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(),
|
||||
Database: NewDefaultDatabaseConfig(),
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
19
internal/config/config_test.go
Normal file
19
internal/config/config_test.go
Normal file
@ -0,0 +1,19 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/davecgh/go-spew/spew"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
func TestConfigLoad(t *testing.T) {
|
||||
filepath := "./testdata/config.yml"
|
||||
|
||||
conf, err := NewFromFile(filepath)
|
||||
if err != nil {
|
||||
t.Fatal(errors.WithStack(err))
|
||||
}
|
||||
|
||||
t.Logf("%s", spew.Sdump(conf))
|
||||
}
|
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,
|
||||
}
|
||||
}
|
19
internal/config/database.go
Normal file
19
internal/config/database.go
Normal file
@ -0,0 +1,19 @@
|
||||
package config
|
||||
|
||||
const (
|
||||
DatabaseDriverSQLite = "sqlite"
|
||||
)
|
||||
|
||||
// DatabaseConfig definition
|
||||
type DatabaseConfig struct {
|
||||
Driver InterpolatedString `yaml:"driver"`
|
||||
DSN InterpolatedString `yaml:"dsn"`
|
||||
}
|
||||
|
||||
// NewDefaultDatabaseConfig return the default database configuration
|
||||
func NewDefaultDatabaseConfig() DatabaseConfig {
|
||||
return DatabaseConfig{
|
||||
Driver: "sqlite",
|
||||
DSN: "sqlite://bouncer.sqlite?_pragma=foreign_keys(1)&_pragma=busy_timeout=10000",
|
||||
}
|
||||
}
|
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),
|
||||
}
|
||||
}
|
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
|
9
internal/datastore/error.go
Normal file
9
internal/datastore/error.go
Normal file
@ -0,0 +1,9 @@
|
||||
package datastore
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrNotFound = errors.New("not found")
|
||||
ErrAlreadyExist = errors.New("already exist")
|
||||
ErrUnexpectedRevision = errors.New("unexpected revision")
|
||||
)
|
33
internal/datastore/proxy_repository.go
Normal file
33
internal/datastore/proxy_repository.go
Normal file
@ -0,0 +1,33 @@
|
||||
package datastore
|
||||
|
||||
type ProxyRepository interface{}
|
||||
|
||||
type InboundID string
|
||||
|
||||
type Inbound struct {
|
||||
ID InboundID
|
||||
Name string
|
||||
Matchers []InboundMatcherID
|
||||
Outbound OutboundID
|
||||
}
|
||||
|
||||
type InboundMatcherID string
|
||||
|
||||
type InboundMatcherType string
|
||||
|
||||
type InboundRuleMatcher struct {
|
||||
ID InboundMatcherID
|
||||
Type InboundMatcherType
|
||||
Options map[string]any
|
||||
}
|
||||
|
||||
type OutboundID string
|
||||
|
||||
type Outbound struct {
|
||||
ID OutboundID
|
||||
Middlewares []MiddlewareID
|
||||
}
|
||||
|
||||
type MiddlewareID string
|
||||
|
||||
type Middleware struct{}
|
19
internal/datastore/sqlite/agent_repository.go
Normal file
19
internal/datastore/sqlite/agent_repository.go
Normal file
@ -0,0 +1,19 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"forge.cadoles.com/cadoles/bouncer/internal/datastore"
|
||||
)
|
||||
|
||||
type ProxyRepository struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewProxyRepository(db *sql.DB) *ProxyRepository {
|
||||
return &ProxyRepository{
|
||||
db: db,
|
||||
}
|
||||
}
|
||||
|
||||
var _ datastore.ProxyRepository = &ProxyRepository{}
|
42
internal/datastore/sqlite/json.go
Normal file
42
internal/datastore/sqlite/json.go
Normal file
@ -0,0 +1,42 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
type JSONMap map[string]any
|
||||
|
||||
func (j *JSONMap) Scan(value interface{}) error {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var data []byte
|
||||
|
||||
switch typ := value.(type) {
|
||||
case []byte:
|
||||
data = typ
|
||||
case string:
|
||||
data = []byte(typ)
|
||||
default:
|
||||
return errors.Errorf("unexpected type '%T'", value)
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &j); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (j JSONMap) Value() (driver.Value, error) {
|
||||
data, err := json.Marshal(j)
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
46
internal/datastore/sqlite/proxy_repository_test.go
Normal file
46
internal/datastore/sqlite/proxy_repository_test.go
Normal file
@ -0,0 +1,46 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"forge.cadoles.com/cadoles/bouncer/internal/datastore/testsuite"
|
||||
"forge.cadoles.com/cadoles/bouncer/internal/migrate"
|
||||
"github.com/pkg/errors"
|
||||
"gitlab.com/wpetit/goweb/logger"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
func TestSQLiteAgentRepository(t *testing.T) {
|
||||
logger.SetLevel(logger.LevelDebug)
|
||||
|
||||
file := "testdata/agent_repository_test.sqlite"
|
||||
|
||||
if err := os.Remove(file); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("%+v", errors.WithStack(err))
|
||||
}
|
||||
|
||||
dsn := fmt.Sprintf("%s?_pragma=foreign_keys(1)&_pragma=busy_timeout=%d", file, (60 * time.Second).Milliseconds())
|
||||
|
||||
migr, err := migrate.New("../../../migrations", "sqlite", "sqlite://"+dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("%+v", errors.WithStack(err))
|
||||
}
|
||||
|
||||
if err := migr.Up(); err != nil {
|
||||
t.Fatalf("%+v", errors.WithStack(err))
|
||||
}
|
||||
|
||||
db, err := sql.Open("sqlite", dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("%+v", errors.WithStack(err))
|
||||
}
|
||||
|
||||
repo := NewProxyRepository(db)
|
||||
|
||||
testsuite.TestProxyRepository(t, repo)
|
||||
}
|
1
internal/datastore/sqlite/testdata/.gitignore
vendored
Normal file
1
internal/datastore/sqlite/testdata/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
*.sqlite*
|
14
internal/datastore/testsuite/proxy_repository.go
Normal file
14
internal/datastore/testsuite/proxy_repository.go
Normal file
@ -0,0 +1,14 @@
|
||||
package testsuite
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"forge.cadoles.com/cadoles/bouncer/internal/datastore"
|
||||
)
|
||||
|
||||
func TestProxyRepository(t *testing.T, repo datastore.ProxyRepository) {
|
||||
t.Run("Cases", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
runProxyRepositoryTests(t, repo)
|
||||
})
|
||||
}
|
46
internal/datastore/testsuite/proxy_repository_cases.go
Normal file
46
internal/datastore/testsuite/proxy_repository_cases.go
Normal file
@ -0,0 +1,46 @@
|
||||
package testsuite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"forge.cadoles.com/cadoles/bouncer/internal/datastore"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
type proxyRepositoryTestCase struct {
|
||||
Name string
|
||||
Skip bool
|
||||
Run func(ctx context.Context, repo datastore.ProxyRepository) error
|
||||
}
|
||||
|
||||
var proxyRepositoryTestCases = []proxyRepositoryTestCase{
|
||||
{
|
||||
Name: "Create a new agent",
|
||||
Run: func(ctx context.Context, repo datastore.ProxyRepository) error {
|
||||
return nil
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
func runProxyRepositoryTests(t *testing.T, repo datastore.ProxyRepository) {
|
||||
for _, tc := range proxyRepositoryTestCases {
|
||||
func(tc proxyRepositoryTestCase) {
|
||||
t.Run(tc.Name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if tc.Skip {
|
||||
t.SkipNow()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
if err := tc.Run(ctx, repo); err != nil {
|
||||
t.Errorf("%+v", errors.WithStack(err))
|
||||
}
|
||||
})
|
||||
}(tc)
|
||||
}
|
||||
}
|
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{}
|
18
internal/format/prop.go
Normal file
18
internal/format/prop.go
Normal file
@ -0,0 +1,18 @@
|
||||
package format
|
||||
|
||||
type Prop struct {
|
||||
name string
|
||||
label string
|
||||
}
|
||||
|
||||
func (p *Prop) Name() string {
|
||||
return p.name
|
||||
}
|
||||
|
||||
func (p *Prop) Label() string {
|
||||
return p.label
|
||||
}
|
||||
|
||||
func NewProp(name, label string) Prop {
|
||||
return Prop{name, label}
|
||||
}
|
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
|
||||
}
|
49
internal/format/table/prop.go
Normal file
49
internal/format/table/prop.go
Normal file
@ -0,0 +1,49 @@
|
||||
package table
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"reflect"
|
||||
|
||||
"forge.cadoles.com/cadoles/bouncer/internal/format"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
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.Interface())
|
||||
}
|
||||
}
|
75
internal/format/table/writer.go
Normal file
75
internal/format/table/writer.go
Normal file
@ -0,0 +1,75 @@
|
||||
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())
|
||||
|
||||
if isCompactMode && len(value) > w.compactModeMaxColumnWidth {
|
||||
value = value[:w.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"
|
||||
)
|
6
internal/imports/sql/sql_import.go
Normal file
6
internal/imports/sql/sql_import.go
Normal file
@ -0,0 +1,6 @@
|
||||
package sql
|
||||
|
||||
import (
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
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")
|
||||
}
|
||||
}
|
30
internal/migrate/migrate.go
Normal file
30
internal/migrate/migrate.go
Normal file
@ -0,0 +1,30 @@
|
||||
package migrate
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/golang-migrate/migrate/v4"
|
||||
_ "github.com/golang-migrate/migrate/v4/database/postgres"
|
||||
_ "github.com/golang-migrate/migrate/v4/database/sqlite"
|
||||
_ "github.com/golang-migrate/migrate/v4/source/file"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
type Migrate = migrate.Migrate
|
||||
|
||||
var (
|
||||
ErrNilVersion = migrate.ErrNilVersion
|
||||
ErrNoChange = migrate.ErrNoChange
|
||||
)
|
||||
|
||||
func New(migrationDir, driver, dsn string) (*migrate.Migrate, error) {
|
||||
migr, err := migrate.New(
|
||||
fmt.Sprintf("file://%s/%s", migrationDir, driver),
|
||||
dsn,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
return migr, nil
|
||||
}
|
19
internal/proxy/init.go
Normal file
19
internal/proxy/init.go
Normal file
@ -0,0 +1,19 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"forge.cadoles.com/cadoles/bouncer/internal/setup"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
func (s *Server) initRepositories(ctx context.Context) error {
|
||||
proxyRepo, err := setup.NewProxyRepository(ctx, s.databaseConfig)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
s.repo = proxyRepo
|
||||
|
||||
return nil
|
||||
}
|
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
|
||||
DatabaseConfig config.DatabaseConfig
|
||||
}
|
||||
|
||||
type OptionFunc func(*Option)
|
||||
|
||||
func defaultOption() *Option {
|
||||
return &Option{
|
||||
ServerConfig: config.NewDefaultProxyServerConfig(),
|
||||
DatabaseConfig: config.NewDefaultDatabaseConfig(),
|
||||
}
|
||||
}
|
||||
|
||||
func WithServerConfig(conf config.ProxyServerConfig) OptionFunc {
|
||||
return func(opt *Option) {
|
||||
opt.ServerConfig = conf
|
||||
}
|
||||
}
|
||||
|
||||
func WithDatabaseConfig(conf config.DatabaseConfig) OptionFunc {
|
||||
return func(opt *Option) {
|
||||
opt.DatabaseConfig = conf
|
||||
}
|
||||
}
|
94
internal/proxy/server.go
Normal file
94
internal/proxy/server.go
Normal file
@ -0,0 +1,94 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
|
||||
"forge.cadoles.com/cadoles/bouncer/internal/config"
|
||||
"forge.cadoles.com/cadoles/bouncer/internal/datastore"
|
||||
"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
|
||||
databaseConfig config.DatabaseConfig
|
||||
repo datastore.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")
|
||||
|
||||
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,
|
||||
databaseConfig: opt.DatabaseConfig,
|
||||
}
|
||||
}
|
43
internal/setup/repository.go
Normal file
43
internal/setup/repository.go
Normal file
@ -0,0 +1,43 @@
|
||||
package setup
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"net/url"
|
||||
|
||||
"forge.cadoles.com/cadoles/bouncer/internal/config"
|
||||
"forge.cadoles.com/cadoles/bouncer/internal/datastore"
|
||||
"forge.cadoles.com/cadoles/bouncer/internal/datastore/sqlite"
|
||||
"github.com/pkg/errors"
|
||||
"gitlab.com/wpetit/goweb/logger"
|
||||
)
|
||||
|
||||
func NewProxyRepository(ctx context.Context, conf config.DatabaseConfig) (datastore.ProxyRepository, error) {
|
||||
driver := string(conf.Driver)
|
||||
dsn := string(conf.DSN)
|
||||
|
||||
var repository datastore.ProxyRepository
|
||||
|
||||
logger.Debug(ctx, "initializing proxy repository", logger.F("driver", driver), logger.F("dsn", dsn))
|
||||
|
||||
switch driver {
|
||||
|
||||
case config.DatabaseDriverSQLite:
|
||||
url, err := url.Parse(dsn)
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
db, err := sql.Open(driver, url.Host+url.Path)
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
repository = sqlite.NewProxyRepository(db)
|
||||
|
||||
default:
|
||||
return nil, errors.Errorf("unsupported database driver '%s'", driver)
|
||||
}
|
||||
|
||||
return repository, nil
|
||||
}
|
Reference in New Issue
Block a user