first working version

This commit is contained in:
ston1th 2021-04-09 22:38:03 +02:00
commit 6da132106f
18 changed files with 519 additions and 96 deletions

200
pkg/api/client/client.go Normal file
View file

@ -0,0 +1,200 @@
package client
import (
"bytes"
"context"
"crypto/tls"
"errors"
"fmt"
"io"
"io/ioutil"
"net"
"net/http"
"net/http/httputil"
"os"
"strconv"
"strings"
"sync"
"time"
)
type Client struct {
sync.Mutex
endpoint string
username string
password string
httpClient *http.Client
debugWriter io.Writer
insecure bool
}
type ClientOption func(*Client)
func WithEndpoint(endpoint string) ClientOption {
return func(client *Client) {
client.endpoint = strings.TrimRight(endpoint, "/")
}
}
func WithDebugWriter(debugWriter io.Writer) ClientOption {
return func(client *Client) {
client.debugWriter = debugWriter
}
}
func WithHTTPClient(httpClient *http.Client) ClientOption {
return func(client *Client) {
client.httpClient = httpClient
}
}
func WithInsecureClient(insecure bool) ClientOption {
return func(client *Client) {
client.insecure = insecure
}
}
func WithCredentials(username, password string) ClientOption {
return func(client *Client) {
client.username = username
client.password = password
}
}
var (
ErrMissingEndpointEnv = errors.New("missing environment variable HAPROXY_LB_ENDPOINT")
ErrMissingUserEnv = errors.New("missing environment variable HAPROXY_LB_USER")
ErrMissingPasswordEnv = errors.New("missing environment variable HAPROXY_LB_PASSWORD")
ErrMissingEndpoint = errors.New("missing Endpoint in config")
ErrMissingUser = errors.New("missing User in config")
ErrMissingPassword = errors.New("missing Password in config")
)
type Config struct {
Endpoint string `json:"endpoint"`
User string `json:"user"`
Password string `json:"password"`
Insecure bool `json:"insecure"`
}
func ClientOptionsFromConfig(cfg Config) (options []ClientOption, err error) {
if cfg.Endpoint == "" {
err = ErrMissingEndpoint
return
}
if cfg.User == "" {
err = ErrMissingUser
return
}
if cfg.Password == "" {
err = ErrMissingPassword
return
}
options = []ClientOption{
WithEndpoint(cfg.Endpoint),
WithCredentials(cfg.User, cfg.Password),
WithInsecureClient(cfg.Insecure),
}
return
}
func ClientOptionsFromEnv() (options []ClientOption, err error) {
endpoint := os.Getenv("HAPROXY_LB_ENDPOINT")
if endpoint == "" {
err = ErrMissingEndpointEnv
return
}
user := os.Getenv("HAPROXY_LB_USER")
if user == "" {
err = ErrMissingUserEnv
return
}
pass := os.Getenv("HAPROXY_LB_PASSWORD")
if pass == "" {
err = ErrMissingPasswordEnv
return
}
insecure, _ := strconv.ParseBool(os.Getenv("HAPROXY_LB_INSECURE"))
options = []ClientOption{
WithEndpoint(endpoint),
WithCredentials(user, pass),
WithInsecureClient(insecure),
}
return
}
func NewClient(options ...ClientOption) *Client {
client := &Client{}
for _, option := range options {
option(client)
}
if client.httpClient == nil {
client.httpClient = &http.Client{Transport: &http.Transport{
DialContext: (&net.Dialer{
Timeout: 30 * time.Second,
}).DialContext,
TLSClientConfig: &tls.Config{
InsecureSkipVerify: client.insecure,
},
}}
}
return client
}
func (c *Client) NewRequest(ctx context.Context, method, path string, body io.Reader) (*http.Request, error) {
url := c.endpoint + path
req, err := http.NewRequest(method, url, body)
if err != nil {
return nil, err
}
req.SetBasicAuth(c.username, c.password)
req = req.WithContext(ctx)
return req, nil
}
var HTTPErr = errors.New("http error")
func (c *Client) Do(r *http.Request, v interface{}) (resp *http.Response, err error) {
if c.debugWriter != nil {
dumpReq, err := httputil.DumpRequestOut(r, true)
if err != nil {
return &http.Response{}, err
}
fmt.Fprintf(c.debugWriter, "--- Request:\n%s\n\n", dumpReq)
}
resp, err = c.httpClient.Do(r)
if err != nil {
return
}
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
resp.Body.Close()
return
}
resp.Body.Close()
resp.Body = ioutil.NopCloser(bytes.NewReader(body))
if c.debugWriter != nil {
dumpResp, err := httputil.DumpResponse(resp, true)
if err != nil {
return resp, err
}
fmt.Fprintf(c.debugWriter, "--- Response:\n%s\n\n", dumpResp)
}
err = decodeResp(resp, v)
if resp.StatusCode >= 400 && resp.StatusCode <= 599 {
if err != nil {
err = fmt.Errorf("%w: %s: %s", HTTPErr, errors.New(resp.Status), err.Error())
return
}
err = fmt.Errorf("%w: %s", HTTPErr, errors.New(resp.Status))
}
return
}

31
pkg/api/client/helper.go Normal file
View file

@ -0,0 +1,31 @@
package client
import (
"encoding/json"
"errors"
"net/http"
)
type Error struct {
Message string `json:"message"`
Status int `json:"status"`
}
func decodeResp(resp *http.Response, v interface{}) error {
if resp.Body == nil {
return nil
}
defer resp.Body.Close()
if resp.StatusCode == 200 {
if v == nil {
return nil
}
return json.NewDecoder(resp.Body).Decode(&v)
}
var e Error
err := json.NewDecoder(resp.Body).Decode(&e)
if err != nil {
return err
}
return errors.New(e.Message)
}

View file

@ -50,14 +50,14 @@ func NewServer(c *config.Config, log logr.Logger) (*Server, error) {
log: log,
cidrs: c.VIP.VirtualIPs,
gw: c.VIP.Gateway,
Data: &types.ContextData{
Auth: c.Server.BasicAuth,
},
Data: &types.ContextData{},
init: make(chan struct{}),
stop: make(chan struct{}),
stopKeyReset: make(chan struct{}),
}
s.Data.Auth = types.NewAuth(c.Server.BasicAuth)
if c.Server.TLS != nil {
cert, err := tls.LoadX509KeyPair(c.Server.TLS.Cert, c.Server.TLS.Key)
if err != nil {

View file

@ -1,4 +1,4 @@
package server
package types
import (
"git.giftfish.de/ston1th/haproxy-lb/pkg/config"
@ -63,7 +63,7 @@ func (c *cache) makeRoom() {
}
}
type auth struct {
type Auth struct {
users map[string]*creds
cache *cache
// bcryptMtx is there to ensure that bcrypt.CompareHashAndPassword is run
@ -71,7 +71,7 @@ type auth struct {
bcryptMtx sync.Mutex
}
func (a *auth) Auth(user, pass, path string) bool {
func (a *Auth) Login(user, pass, path string) bool {
creds, valid := a.users[user]
hash := creds.Hash
@ -103,13 +103,13 @@ type creds struct {
Prefix string
}
func newAuth(conf config.Config) (a *auth) {
a = &auth{
func NewAuth(conf []config.BasicAuth) (a *Auth) {
a = &Auth{
users: make(map[string]*creds),
cache: newCache(),
}
for _, u := range conf.Server.BasicAuth {
a.users[u.User] = &creds{u.Hash, u.Prefix}
for _, ba := range conf {
a.users[ba.User] = &creds{ba.Hash, ba.Prefix}
}
return
}

View file

@ -9,14 +9,13 @@ import (
"github.com/gorilla/mux"
"git.giftfish.de/ston1th/haproxy-lb/pkg/alloc"
"git.giftfish.de/ston1th/haproxy-lb/pkg/config"
"git.giftfish.de/ston1th/haproxy-lb/pkg/db"
)
type ContextData struct {
Alloc *alloc.Alloc
DB *db.DB
Auth []config.BasicAuth
Auth *Auth
}
func NewContext(w http.ResponseWriter, r *http.Request, data *ContextData, log logr.Logger) *Context {
@ -47,6 +46,11 @@ func (c *Context) Method() string {
return c.Request.Method
}
// Path returns the request URL path
func (c *Context) Path() string {
return c.Request.URL.Path
}
// GetHeader returns the given http header
func (c *Context) GetHeader(name string) string {
return c.Request.Header.Get(name)
@ -82,16 +86,8 @@ func (c *Context) NotFound() {
c.Err(ErrNotFound)
}
//TODO func (c *Context) MethodNotAllowed() {
// c.Status = http.StatusMethodNotAllowed
// c.Response.WriteHeader(http.StatusMethodNotAllowed)
// c.Response.Write([]byte(`{"message":"method not allowed","status":405}`))
// c.log()
//}
// OK is a empty HTTP 200 JSON response
func (c *Context) OK() {
c.Response.Write([]byte("{}"))
c.log()
}

View file

@ -35,11 +35,12 @@ func newError(err string, status int) Error {
}
var (
ErrBadreq = newError("bad request", http.StatusBadRequest)
ErrInvalid = newError("invalid data", http.StatusBadRequest)
ErrForbidden = newError("forbidden", http.StatusForbidden)
ErrNotFound = newError("not found", http.StatusNotFound)
ErrMNA = newError("method not allowed", http.StatusMethodNotAllowed)
ErrExists = newError("resource already exists", http.StatusConflict)
ErrISE = newError("internal server error", http.StatusInternalServerError)
ErrBadreq = newError("bad request", http.StatusBadRequest)
ErrInvalid = newError("invalid data", http.StatusBadRequest)
ErrUnauthorized = newError("unauthorized", http.StatusUnauthorized)
ErrForbidden = newError("forbidden", http.StatusForbidden)
ErrNotFound = newError("not found", http.StatusNotFound)
ErrMNA = newError("method not allowed", http.StatusMethodNotAllowed)
ErrExists = newError("resource already exists", http.StatusConflict)
ErrISE = newError("internal server error", http.StatusInternalServerError)
)

View file

@ -0,0 +1,85 @@
package client
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
apiclient "git.giftfish.de/ston1th/haproxy-lb/pkg/api/client"
"git.giftfish.de/ston1th/haproxy-lb/pkg/api/v1/schema"
)
var (
ErrClientIsNil = errors.New("client is nil")
base = "/" + schema.Version
)
type Client struct {
c *apiclient.Client
}
func NewClient(c *apiclient.Client) (*Client, error) {
if c == nil {
return nil, ErrClientIsNil
}
return &Client{c}, nil
}
func (c *Client) GetLoadBalancers(ctx context.Context) (lbs map[string]schema.LoadBalancer, err error) {
return c.GetLoadBalancersWithCluster(ctx, "")
}
func (c *Client) GetLoadBalancersWithCluster(ctx context.Context, cluster string) (lbs map[string]schema.LoadBalancer, err error) {
path := base + "/lb"
if cluster != "" {
path += "/" + cluster
}
req, err := c.c.NewRequest(ctx, "GET", path, nil)
if err != nil {
return
}
lbs = make(map[string]schema.LoadBalancer)
_, err = c.c.Do(req, &lbs)
return
}
func (c *Client) GetLoadBalancer(ctx context.Context, cluster, name string) (lb schema.LoadBalancer, err error) {
path := fmt.Sprintf("%s/lb/%s/%s", base, cluster, name)
req, err := c.c.NewRequest(ctx, "GET", path, nil)
if err != nil {
return
}
_, err = c.c.Do(req, &lb)
return
}
func (c *Client) CreateLoadBalancer(ctx context.Context, cluster, name string, lb schema.LoadBalancer) (err error) {
buf := new(bytes.Buffer)
path := fmt.Sprintf("%s/lb/%s/%s", base, cluster, name)
err = lb.ValidateClient()
if err != nil {
return
}
err = json.NewEncoder(buf).Encode(lb)
if err != nil {
return
}
req, err := c.c.NewRequest(ctx, "POST", path, buf)
if err != nil {
return
}
_, err = c.c.Do(req, nil)
return
}
func (c *Client) DeleteLoadBalancer(ctx context.Context, cluster, name string) (err error) {
path := fmt.Sprintf("%s/lb/%s/%s", base, cluster, name)
req, err := c.c.NewRequest(ctx, "DELETE", path, nil)
if err != nil {
return
}
_, err = c.c.Do(req, nil)
return
}

View file

@ -14,7 +14,7 @@ healthCheckNodePort
const Version = "v1"
type LoadBalancer struct {
Name string `json:"name"`
Name string `json:"name,omitempty"`
IP string `json:"ip,omitempty"`
Options Options `json:"options,omitempty"`
Ports []Port `json:"ports,omitempty"`
@ -37,13 +37,7 @@ type Server struct {
Port int `json:"port,omitempty"`
}
func (lb LoadBalancer) Validate() error {
if lb.Name == "" {
return errors.New("LoadBalancer.Name can not be empty")
}
if lb.IP == "" {
return errors.New("LoadBalancer.IP can not be empty")
}
func (lb LoadBalancer) ValidateClient() error {
for i, p := range lb.Ports {
if p.Port < 0 || p.Port > 65535 {
return fmt.Errorf("LoadBalancer.Ports[%d].Port is invalid (range 0-65535", i)
@ -63,6 +57,16 @@ func (lb LoadBalancer) Validate() error {
return nil
}
func (lb LoadBalancer) ValidateServer() error {
if lb.Name == "" {
return errors.New("LoadBalancer.Name can not be empty")
}
if lb.IP == "" {
return errors.New("LoadBalancer.IP can not be empty")
}
return lb.ValidateClient()
}
func (lb LoadBalancer) JSON() ([]byte, error) {
return json.Marshal(lb)
}

View file

@ -5,6 +5,7 @@ import (
//"net/http"
"context"
"encoding/json"
"net"
"git.giftfish.de/ston1th/haproxy-lb/pkg/api/types"
"git.giftfish.de/ston1th/haproxy-lb/pkg/api/v1/schema"
@ -13,10 +14,15 @@ import (
func authHandler(h types.CtxHandler) types.CtxHandler {
return func(ctx *types.Context) {
// TODO
// test:123456
// test:$2b$10$zNfGTAQ94vifj2wtGsv1W.yZRe4/rDBzQixpB6FbrTed4BnHuNqBS
//bcrypt.CompareHashAndPassword(hash, pw)
user, pass, ok := ctx.Request.BasicAuth()
if !ok {
ctx.Err(types.ErrUnauthorized)
return
}
if !ctx.Data.Auth.Login(user, pass, ctx.Path()) {
ctx.Err(types.ErrForbidden)
return
}
h(ctx)
}
}
@ -34,6 +40,14 @@ func lbname(c, n string) string {
return c + "_" + n
}
func getIP(cidr string) (string, error) {
ip, _, err := net.ParseCIDR(cidr)
if err != nil {
return "", err
}
return ip.String(), nil
}
func lbHandler(ctx *types.Context) {
cl := ctx.Var("cluster")
n := ctx.Var("name")
@ -52,9 +66,6 @@ func lbHandler(ctx *types.Context) {
}
ctx.Body(b)
case "POST":
// TODO
// handle if loadbalancer already exists
// decode existing LB and read Name + IP from there
b, err := ctx.ReadBody()
if err != nil {
ctx.Log.Error(err, "error reading request body", "cluster", cl, "name", n)
@ -69,13 +80,28 @@ func lbHandler(ctx *types.Context) {
return
}
lb.Name = lbname(cl, n)
lb.IP, err = ctx.Data.Alloc.AllocIP(context.Background(), name)
if err != nil {
ctx.Log.Error(err, "error allocating IP for loadbalancer", "cluster", cl, "name", n)
cidr, err := ctx.Data.DB.GetIP(name)
if err != nil && err != cluster.ErrKeyNotFound {
ctx.Log.Error(err, "error getting existing loadbalancer ip", "cluster", cl, "name", n)
ctx.Err(types.ErrISE)
return
}
err = lb.Validate()
if cidr != "" {
lb.IP, err = getIP(cidr)
if err != nil {
ctx.Log.Error(err, "error parsing existing ip", "cluster", cl, "name", n, "cidr", cidr)
ctx.Err(types.ErrISE)
return
}
} else {
lb.IP, err = ctx.Data.Alloc.AllocIP(context.Background(), name)
if err != nil {
ctx.Log.Error(err, "error allocating ip for loadbalancer", "cluster", cl, "name", n)
ctx.Err(types.ErrISE)
return
}
}
err = lb.ValidateServer()
if err != nil {
ctx.Log.Error(err, "error validating loadbalancer", "cluster", cl, "name", n)
ctx.Err(types.ErrInvalid)
@ -95,8 +121,12 @@ func lbHandler(ctx *types.Context) {
}
ctx.OK()
case "DELETE":
if !ctx.Data.DB.LBExists(name) {
ctx.Err(types.ErrNotFound)
return
}
err := ctx.Data.Alloc.FreeIP(context.Background(), name)
if err != nil {
if err != nil && err != cluster.ErrKeyNotFound {
ctx.Log.Error(err, "error freeing IP of loadbalancer", "cluster", cl, "name", n)
ctx.Err(types.ErrISE)
return

View file

@ -24,7 +24,7 @@ var Routes = []types.Route{
[]string{"GET"},
},
{
v1 + "/lb/{cluster:[a-zA-Z0-9-]+}/{name:[a-zA-Z0-9-]+$}",
v1 + "/lb/{cluster:[a-zA-Z0-9-]+}/{name:[a-zA-Z0-9-_]+$}",
authHandler(lbHandler),
[]string{"GET", "POST", "DELETE"},
},