initial commit

This commit is contained in:
ston1th 2020-11-22 12:59:12 +01:00
commit 7715fcf373
37 changed files with 2957 additions and 0 deletions

7
pkg/api/api.go Normal file
View file

@ -0,0 +1,7 @@
package api
type Route struct {
Path string
Handler http.HandlerFunc
Methods []string
}

View file

@ -0,0 +1,34 @@
package schema
/*
healthCheckNodePort
*/
type LBConfig struct {
LoadBalancers []LoadBalancer
}
type LoadBalancer struct {
Name string `json:"name"`
IP string `json:"ip"`
Options Options `json:"options,omitempty"`
Ports []Port `json:"ports,omitempty"`
}
type Options struct {
Frontend []string `json:"frontend,omitempty"`
Backend []string `json:"backend,omitempty"`
DefaultServer string `json:"defaultServer,omitempty`
}
type Port struct {
Port int `json:"port,omitempty"`
Servers []Server `json:"servers,omitempty"`
}
type Server struct {
Name string `json:"name,omitempty"`
IP string `json:"ip,omitempty"`
Port int `json:"port,omitempty"`
}

View file

@ -0,0 +1,103 @@
package server
import (
"encoding/json"
"fmt"
"net/http"
"github.com/gorilla/mux"
)
func newContext(w http.ResponseWriter, r *http.Request, srv *Server) *Context {
h := w.Header()
h.Set("Content-Type", "application/json")
return &Context{
Status: http.StatusOK,
Request: r,
Response: w,
s: srv,
}
}
// Context is the context object shared between http handlers
type Context struct {
Body []byte
Status int
Request *http.Request
Response http.ResponseWriter
s *Server
}
// Method returns the request method
func (c *Context) Method() string {
return c.Request.Method
}
// GetHeader returns the given http header
func (c *Context) GetHeader(name string) string {
return c.Request.Header.Get(name)
}
// SetHeader sets the given http header
func (c *Context) SetHeader(name, value string) {
h := c.Response.Header()
h.Set(name, value)
}
// Form returns the given form value
func (c *Context) Form(name string) string {
return c.Request.FormValue(name)
}
// Var returns the given url variable
func (c *Context) Var(name string) (ret string) {
ret, _ = mux.Vars(c.Request)[name]
return
}
func (c *Context) log() {
c.s.log.Info("access",
"addr", c.Request.RemoteAddr,
"method", c.Request.Method,
"url", c.Request.URL,
"status", c.Status,
)
}
func (c *Context) NotFound() {
c.Status = http.StatusNotFound
c.Response.WriteHeader(http.StatusNotFound)
c.Response.Write([]byte(`{"message":"not found","status":404}`))
c.log()
}
// OK is a empty HTTP 200 JSON response
func (c *Context) OK() {
c.Response.Write([]byte("{}"))
c.log()
}
// JSON is a json response
func (c *Context) JSON(v interface{}) {
err := json.NewEncoder(c.Response).Encode(v)
if err != nil {
c.Err(ise)
return
}
c.log()
}
// Err is a HTTPErr wrapper for the custom Error type
func (c *Context) Err(err Error) {
c.HTTPErr(err.Error(), err.Status())
}
// HTTPErr is a http.Error wrapper
func (c *Context) HTTPErr(err string, status int) {
c.Status = status
http.Error(c.Response, err, status)
c.log()
}
type ctxHandler func(*Context)

View file

@ -0,0 +1,24 @@
package server
import (
"net/http"
)
type notFoundHandler struct {
s *Server
}
func (nf *notFoundHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
newContext(w, r, nf.s).NotFound()
}
func authHandler(h ctxHandler) ctxHandler {
return func(ctx *Context) {
// TODO
h(ctx)
}
}
func healthzHandler(ctx *Context) {
ctx.OK()
}

View file

@ -0,0 +1,21 @@
package server
import (
"git.giftfish.de/ston1th/haproxy-lb/pkg/api"
"git.giftfish.de/ston1th/haproxy-lb/pkg/api/v1/schema"
)
const version = "/" + schema.Version
var routes = []api.Route{
{
"/healthz",
healthzHandler,
[]string{"GET"},
},
{
version + "/alloc",
authHandler(allocHandler),
[]string{"POST"},
},
}

View file

@ -0,0 +1,92 @@
package server
import (
"context"
stdtls "crypto/tls"
"io/ioutil"
"log"
"net"
"net/http"
"path/filepath"
"time"
"github.com/go-logr/logr"
"github.com/gorilla/mux"
)
// Server is the webapp and api server
type Server struct {
srv *http.Server
mux *mux.Router
log logr.Logger
stop chan struct{}
stopKeyReset chan struct{}
cert string
key string
laddr string
debug bool
}
// NewHTTPServer returns a new HTTPServer
func NewServer(log logr.Logger) *Server {
s := &Server{
mux: mux.NewRouter(),
log: log,
stop: make(chan struct{}),
stopKeyReset: make(chan struct{}),
cert: filepath.Join(core.C.DataDir, core.C.HTTP.Cert),
key: filepath.Join(core.C.DataDir, core.C.HTTP.Key),
laddr: net.JoinHostPort(core.C.HTTP.Address, core.C.HTTP.Port),
debug: core.C.Debug,
}
s.mux.NotFoundHandler = &notFoundHandler{s}
for _, v := range routes {
s.mux.HandleFunc(v.Path, s.contextWrapper(v.Handler)).Methods(v.Methods...)
}
s.start()
return s
}
func (s *Server) start() error {
l, err := tls.Listen("tcp", h.laddr, cfg)
if err != nil {
return err
}
h.srv = &http.Server{
Handler: h.mux,
TLSConfig: cfg,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
}
go func() {
err := h.srv.Serve(l)
if err != nil {
s.log.Error(err, "")
}
}()
return nil
}
func (s *Server) contextWrapper(h ctxHandler) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
h(newContext(w, r, s))
}
}
// Stop stops listening for incoming connections and closes currently open connections
func (s *Server) Stop() {
s.log.Info("stopping")
ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
defer cancel()
err := h.srv.Shutdown(ctx)
if err != nil {
s.log.Error(err, "")
}
s.log.Info("stopped")
}

33
pkg/cluster/cluster.go Normal file
View file

@ -0,0 +1,33 @@
package cluster
import (
"context"
"git.giftfish.de/ston1th/vipman/pkg/config"
"github.com/go-logr/logr"
)
type Cluster interface {
Start(*config.Config) error
SetCallbacks(Callbacks) error
Stepdown()
Stop()
}
type KV interface {
Get(k string) ([]byte, error)
Set(k string, v []byte) error
Delete(k string) error
}
type CallbackContext interface {
logr.Logger
KV
ID() string
}
type Callbacks struct {
Leader func(context.Context, CallbackContext)
Follower func(context.Context, CallbackContext)
Cleanup func(context.Context, CallbackContext)
}

19
pkg/config/ca.go Normal file
View file

@ -0,0 +1,19 @@
package config
import (
"crypto/x509"
"io/ioutil"
)
func LoadCertPool(file string) (p *x509.CertPool, err error) {
if file == "" {
return
}
buf, err := ioutil.ReadFile(file)
if err != nil {
return
}
p = x509.NewCertPool()
p.AppendCertsFromPEM(buf)
return
}

159
pkg/config/config.go Normal file
View file

@ -0,0 +1,159 @@
package config
import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"gopkg.in/yaml.v2"
)
const (
RaftDir = "raft"
RaftPort = 7001
RaftLogLevel = "off"
EtcdLogLevel = "error"
EtcdPrefix = "/vipman"
)
func ParseFile(file string) (cfg *Config, err error) {
if file == "" {
return nil, errors.New("missing config file")
}
f, err := os.Open(file)
if err != nil {
return
}
defer f.Close()
cfg = new(Config)
err = yaml.NewDecoder(f).Decode(cfg)
if err != nil {
return
}
err = Validate(cfg)
return
}
func Validate(c *Config) error {
if len(c.VirtualIPs) == 0 {
return errors.New("missing virtualIPs config")
}
if c.LeaderHook != "" {
if !filepath.IsAbs(c.LeaderHook) {
return errors.New("leaderHook path must be absolute")
}
}
if c.FollowerHook != "" {
if !filepath.IsAbs(c.FollowerHook) {
return errors.New("followerHook path must be absolute")
}
}
raft := c.Cluster.Raft
if raft != nil {
if raft.LogLevel == "" {
raft.LogLevel = RaftLogLevel
}
if raft.Dir == "" {
raft.Dir = RaftDir
}
if raft.ID == "" {
return errors.New("missing raft.id config")
}
if raft.Address == "" {
return errors.New("missing raft.address config")
}
if len(raft.Peers) < 2 {
return errors.New("minimum number of remote peers: 2")
}
for i, v := range raft.Peers {
if v.ID == "" {
return fmt.Errorf("missing raft.peers[%d].id config", i)
}
if v.Address == "" {
return fmt.Errorf("missing raft.peers[%d].address config", i)
}
}
}
etcd := c.Cluster.Etcd
if etcd != nil {
if etcd.LogLevel == "" {
etcd.LogLevel = EtcdLogLevel
}
if len(etcd.Endpoints) == 0 {
return errors.New("missing etcd.endpoints config")
}
if etcd.Prefix == "" {
etcd.Prefix = EtcdPrefix
} else {
if !strings.HasPrefix(etcd.Prefix, "/") {
etcd.Prefix = "/" + etcd.Prefix
}
if strings.HasSuffix(etcd.Prefix, "/") {
etcd.Prefix = strings.TrimSuffix(etcd.Prefix, "/")
}
}
if etcd.ClusterName == "" {
return errors.New("missing etcd.clusterName config")
} else {
if strings.HasSuffix(etcd.ClusterName, "/") {
etcd.ClusterName = strings.TrimSuffix(etcd.ClusterName, "/")
}
}
}
if raft == nil && etcd == nil {
return errors.New("missing cluster config: raft or etcd")
}
if raft != nil && etcd != nil {
return errors.New("only one cluster config allowed: raft or etcd")
}
return nil
}
type Config struct {
VirtualIPs []string `yaml:"virtualIPs"`
Interface string `yaml:"interface,omitempty"`
Label string `yaml:"label,omitempty"`
LeaderHook string `yaml:"leaderHook,omitempty"`
FollowerHook string `yaml:"followerHook,omitempty"`
Cluster Cluster `yaml:"cluster"`
}
type Cluster struct {
Raft *Raft `yaml:"raft,omitempty"`
Etcd *Etcd `yaml:"etcd,omitempty"`
}
type Raft struct {
Dir string `yaml:"dir,omitempty"`
ID string `yaml:"id"`
Address string `yaml:"address"`
Peers []RaftPeer `yaml:"peers"`
TLS *TLS `yaml:"tls,omitempty"`
LogLevel string `yaml:"logLevel,omitempty"`
}
type TLS struct {
Cert string `yaml:"cert,omitempty"`
Key string `yaml:"key,omitempty"`
CA string `yaml:"ca,omitempty"`
Insecure bool `yaml:"insecure,omitempty"`
}
// RaftPeer details the configuration of all cluster peers
type RaftPeer struct {
ID string `yaml:"id"`
Address string `yaml:"address"`
}
type Etcd struct {
Endpoints []string `yaml:"endpoints"`
Prefix string `yaml:"prefix"`
ClusterName string `yaml:"clusterName"`
Username string `yaml:"username,omitempty"`
Password string `yaml:"password,omitempty"`
TLS *TLS `yaml:"tls,omitempty"`
LogLevel string `yaml:"logLevel,omitempty"`
}

91
pkg/controller/vip.go Normal file
View file

@ -0,0 +1,91 @@
package controller
import (
"context"
"os/exec"
"time"
"git.giftfish.de/ston1th/vipman/pkg/cluster"
"git.giftfish.de/ston1th/vipman/pkg/config"
"git.giftfish.de/ston1th/vipman/pkg/vip"
"github.com/go-logr/logr"
)
func NewVIPController(cfg *config.Config) (callbacks cluster.Callbacks, err error) {
n, err := vip.NewNetworkWithLabel(cfg.Interface, cfg.Label)
if err != nil {
return
}
callbacks = cluster.Callbacks{
Leader: func(ctx context.Context, cc cluster.CallbackContext) {
t := time.NewTicker(time.Second * 10)
if cfg.LeaderHook != "" {
go func() {
err := exec.CommandContext(ctx, cfg.LeaderHook).Run()
if err != nil {
cc.Error(err, "error running hook", "leaderHook", cfg.LeaderHook)
}
}()
}
for {
cc.Info("leading", "id", cc.ID())
addIPs(cc, n, cfg.VirtualIPs)
select {
case <-ctx.Done():
t.Stop()
deleteIPs(cc, n, cfg.VirtualIPs)
cc.Info("leading canceled", "id", cc.ID())
return
case <-t.C:
}
}
},
Follower: func(ctx context.Context, cc cluster.CallbackContext) {
t := time.NewTicker(time.Second * 10)
if cfg.FollowerHook != "" {
go func() {
err := exec.CommandContext(ctx, cfg.FollowerHook).Run()
if err != nil {
cc.Error(err, "error running hook", "followerHook", cfg.FollowerHook)
}
}()
}
for {
cc.Info("following", "id", cc.ID())
//TODO
//deleteIPs(cc, n, cfg.VirtualIPs)
select {
case <-ctx.Done():
t.Stop()
cc.Info("following canceled", "id", cc.ID())
return
case <-t.C:
}
}
},
Cleanup: func(ctx context.Context, cc cluster.CallbackContext) {
deleteIPs(cc, n, cfg.VirtualIPs)
},
}
return
}
func addIPs(log logr.Logger, n *vip.Network, cidrs []string) error {
for _, cidr := range cidrs {
err := n.AddIP(cidr)
if err != nil {
log.Error(err, "adding ip", "interface", n.Interface(), "ip", cidr)
}
}
return nil
}
func deleteIPs(log logr.Logger, n *vip.Network, cidrs []string) error {
for _, cidr := range cidrs {
err := n.DeleteIP(cidr)
if err != nil {
log.Error(err, "deleting ip", "interface", n.Interface(), "ip", cidr)
}
}
return nil
}

82
pkg/etcd/cluster.go Normal file
View file

@ -0,0 +1,82 @@
package etcd
import (
"context"
"errors"
"git.giftfish.de/ston1th/vipman/pkg/cluster"
//"go.etcd.io/etcd/clientv3"
"github.com/coreos/etcd/clientv3"
"github.com/go-logr/logr"
)
var ErrKeyNotFound = errors.New("key not found")
type Cluster struct {
logr.Logger
cli *clientv3.Client
ctx context.Context
stepdown chan struct{}
stop chan struct{}
done chan struct{}
callbacks cluster.Callbacks
kvPrefix string
id string
keyID string
session bool
cancelSession func()
}
func NewCluster(log logr.Logger) cluster.Cluster {
return &Cluster{
Logger: log,
ctx: context.Background(),
stepdown: make(chan struct{}, 1),
stop: make(chan struct{}, 1),
done: make(chan struct{}, 1),
}
}
func (c *Cluster) Get(k string) (v []byte, err error) {
r, err := c.cli.Get(c.ctx, c.kvPrefix+"/"+k)
if err != nil {
return
}
if len(r.Kvs) == 0 {
err = ErrKeyNotFound
return
}
v = r.Kvs[0].Value
return
}
func (c *Cluster) Set(k string, v []byte) (err error) {
_, err = c.cli.Put(c.ctx, c.kvPrefix+"/"+k, string(v))
return
}
func (c *Cluster) Delete(k string) (err error) {
_, err = c.cli.Delete(c.ctx, c.kvPrefix+"/"+k)
return
}
func (c *Cluster) SetCallbacks(callbacks cluster.Callbacks) error {
c.callbacks = callbacks
return c.checkConfig()
}
func (c *Cluster) ID() string {
return c.id
}
func (c *Cluster) checkConfig() error {
if c.callbacks.Leader == nil {
return errors.New("Leader callback is nil")
}
if c.callbacks.Follower == nil {
return errors.New("Follower callback is nil")
}
return nil
}

223
pkg/etcd/etcd.go Normal file
View file

@ -0,0 +1,223 @@
package etcd
import (
"context"
"crypto/tls"
"fmt"
"time"
"git.giftfish.de/ston1th/vipman/pkg/config"
"git.giftfish.de/ston1th/vipman/pkg/util"
"github.com/coreos/etcd/clientv3"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
//"go.etcd.io/etcd/clientv3"
"go.etcd.io/etcd/clientv3/concurrency"
)
func (c *Cluster) Start(etcdcfg *config.Config) error {
err := c.checkConfig()
if err != nil {
return err
}
cfg := etcdcfg.Cluster.Etcd
var level zapcore.Level
err = level.Set(cfg.LogLevel)
if err != nil {
return err
}
al := zap.NewAtomicLevel()
al.SetLevel(level)
var tlsConfig *tls.Config
if cfg.TLS != nil {
ca, err := config.LoadCertPool(cfg.TLS.CA)
if err != nil {
return err
}
tlsConfig = &tls.Config{
MinVersion: tls.VersionTLS12,
InsecureSkipVerify: cfg.TLS.Insecure,
RootCAs: ca,
}
}
c.cli, err = clientv3.New(clientv3.Config{
Endpoints: cfg.Endpoints,
TLS: tlsConfig,
DialTimeout: time.Second * 5,
Username: cfg.Username,
Password: cfg.Password,
LogConfig: &zap.Config{
Level: al,
Encoding: "console",
DisableCaller: true,
DisableStacktrace: true,
EncoderConfig: zap.NewDevelopmentEncoderConfig(),
OutputPaths: []string{"stderr"},
ErrorOutputPaths: []string{"stderr"},
},
})
if err != nil {
return err
}
ctxSsession, cancelSession := context.WithCancel(context.Background())
c.cancelSession = cancelSession
s, err := concurrency.NewSession(c.cli,
concurrency.WithContext(ctxSsession),
concurrency.WithTTL(10),
)
if err != nil {
return err
}
c.session = true
prefix := cfg.Prefix + "/" + cfg.ClusterName
c.kvPrefix = prefix + "/kv"
ePrefix := prefix + "/e"
c.id = fmt.Sprintf("%x", s.Lease())
c.keyID = ePrefix + "/" + c.id
e := concurrency.NewElection(s, ePrefix)
ctxCampaign, cancelCampaign := context.WithCancel(context.Background())
go func() {
errc := make(chan error, 1)
for {
go func() {
errc <- e.Campaign(ctxCampaign, "")
}()
select {
case err := <-errc:
if err != nil {
time.Sleep(time.Second)
}
case <-ctxCampaign.Done():
return
}
}
}()
t := time.NewTicker(time.Second)
ctx, cancel := context.WithCancel(context.Background())
ctxObserve, cancelObserve := context.WithCancel(context.Background())
leading := false
following := false
leaderChan := c.observe(ctxObserve, e.Observe(ctxObserve))
for {
if !leading && c.leaderID(e.Leader(ctxObserve)) == c.keyID {
leading = true
go c.leader(ctx)
} else if !following && !leading {
following = true
go c.follower(ctx)
}
select {
case <-t.C:
case leader := <-leaderChan:
if leader {
if following {
cancel()
following = false
ctx, cancel = context.WithCancel(context.Background())
}
if !leading {
leading = true
go c.leader(ctx)
}
} else {
if leading {
cancel()
leading = false
ctx, cancel = context.WithCancel(context.Background())
}
if !following {
following = true
go c.follower(ctx)
}
}
case <-c.stepdown:
cancel()
ctx, cancel = context.WithCancel(context.Background())
ctxResign, _ := context.WithTimeout(context.Background(), time.Second*2)
e.Resign(ctxResign)
case <-c.stop:
t.Stop()
cancel()
cancelObserve()
cancelCampaign()
if leading {
ctx, _ := context.WithTimeout(context.Background(), time.Second*2)
e.Resign(ctx)
}
if c.callbacks.Cleanup != nil {
ctx, _ := context.WithTimeout(context.Background(), time.Second*2)
c.callbacks.Cleanup(ctx, c)
}
s.Close()
close(c.done)
return nil
}
}
return nil
}
func (c *Cluster) leaderID(r *clientv3.GetResponse, err error) string {
if err != nil {
return ""
}
if len(r.Kvs) == 0 {
return ""
}
return string(r.Kvs[0].Key)
}
func (c *Cluster) observe(ctx context.Context, resp <-chan clientv3.GetResponse) <-chan bool {
leader := make(chan bool)
go func() {
for {
select {
case r := <-resp:
if c.leaderID(&r, nil) == c.keyID {
leader <- true
} else {
leader <- false
}
case <-ctx.Done():
return
}
}
}()
return leader
}
func (c *Cluster) Stepdown() {
select {
case c.stepdown <- struct{}{}:
case <-time.After(time.Second * 2):
}
}
func (c *Cluster) Stop() {
if !c.session {
c.cancelSession()
return
}
close(c.stop)
<-c.done
c.cli.Close()
}
func (c *Cluster) leader(ctx context.Context) {
defer func() {
util.HandleCrash()
}()
c.callbacks.Leader(ctx, c)
}
func (c *Cluster) follower(ctx context.Context) {
defer func() {
util.HandleCrash()
}()
c.callbacks.Follower(ctx, c)
}

67
pkg/haproxy/haproxy.go Normal file
View file

@ -0,0 +1,67 @@
package haproxy
import (
"context"
"io/ioutil"
"os"
"sync"
"text/template"
)
type ServiceManager interface {
Reload()
}
type HAProxyManager struct {
sync.Mutex
configFile string
serviceManager ServiceManager
template *template.Template
}
func NewHAProxyManager(configFile string) (*HAProxyManager, error) {
t, err := template.New("haproxy").Parse(haproxyTemplate)
if err != nil {
return nil, err
}
return &HAProxyManager{
configFile: configFile,
serviceManager: NewSystemdManager("haproxy"),
template: t,
}, nil
}
func (ha *HAProxyManager) checkConfig(ctx context.Context, configFile string) error {
return exec.CommandContext(ctx, "haproxy", "-c", "-f", configFile).Run()
}
func (ha *HAProxyManager) UpdateConfig(ctx context.Context, lbs []config.LoadBalancer) error {
ha.Lock()
defer ha.Unlock()
tmp, err := ioutil.TempFile(os.TempDir(), "haproxy_")
if err != nil {
return err
}
defer tmp.Close()
defer os.Remove(tmp.Name())
err = ha.template.Execute(tmp, lbs)
if err != nil {
return err
}
err = ha.checkConfig(ctx, tmp.Name())
if err != nil {
return err
}
file, err := os.OpenFile(ha.configFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644)
if err != nil {
return err
}
err = ha.template.Execute(file, lbs)
if err != nil {
file.Close()
return err
}
file.Close()
return ha.serviceManager.Reload()
}

20
pkg/haproxy/systemd.go Normal file
View file

@ -0,0 +1,20 @@
package haproxy
import (
"context"
"os/exec"
)
const systemctl = "systemctl"
type SystemdManager struct {
service string
}
func NewSystemdManager(service string) *SystemdManager {
return &SystemdManager{service}
}
func (sm *SystemdManager) Reload(ctx context.Context) error {
return exec.CommandContext(ctx, systemctl, "reload", sm.service).Run()
}

43
pkg/haproxy/template.go Normal file
View file

@ -0,0 +1,43 @@
package haproxy
const haproxyConfigTemplate = `global
daemon
chroot /var/lib/haproxy
user haproxy
group haproxy
maxconn 5000
defaults
mode tcp
log global
retries 2
timeout connect 3000ms
timeout server 5000ms
timeout client 5000ms
timeout tunnel 3600s
timeout check 10s
{{ range $item := . -}}
{{ range $port := $item.Ports -}}
frontend fr_{{ $item.Name }}_{{ $port.Port }}
bind {{ $item.IP }}:{{ $port.Port }}
{{ range $opt := $item.Options.Frontend -}}
option {{ $opt }}
{{ end -}}
# option tcplog
# option splice-request
default_backend ba_{{ $item.Name }}_{{ $port.Port }}
backend ba_{{ $item.Name }}_{{ $port.Port }}
balance roundrobin
{{ range $opt := $item.Options.Backend -}}
option {{ $opt }}
{{ end -}}
# option splice-response
# option httpchk GET /
default-server inter 3s downinter 10s fall 2 rise 2 on-marked-down shutdown-sessions {{ $item.Options.DefaultServer }}
# send-proxy-v2
# check-send-proxy
# port <healthCheckNodePort>
{{ range $srv := $port.Servers -}}
server {{ $srv.Name }} {{ $srv.IP }}:{{ $srv.Port }} check
{{ end -}}
{{ end -}}
{{ end -}}`

56
pkg/raft/cluster.go Normal file
View file

@ -0,0 +1,56 @@
package raft
import (
"errors"
"git.giftfish.de/ston1th/vipman/pkg/cluster"
"github.com/go-logr/logr"
"github.com/hashicorp/raft"
)
type Cluster struct {
logr.Logger
cluster.KV
r *raft.Raft
kvm *kv
stepdown chan struct{}
stop chan struct{}
done chan struct{}
callbacks cluster.Callbacks
id string
}
func NewCluster(log logr.Logger) cluster.Cluster {
kvm := &kv{
m: make(map[string][]byte),
}
return &Cluster{
Logger: log,
KV: kvm,
kvm: kvm,
stepdown: make(chan struct{}, 1),
stop: make(chan struct{}, 1),
done: make(chan struct{}, 1),
}
}
func (c *Cluster) ID() string {
return c.id
}
func (c *Cluster) SetCallbacks(callbacks cluster.Callbacks) error {
c.callbacks = callbacks
return c.checkConfig()
}
func (c *Cluster) checkConfig() error {
if c.callbacks.Leader == nil {
return errors.New("Leader callback is nil")
}
if c.callbacks.Follower == nil {
return errors.New("Follower callback is nil")
}
return nil
}

99
pkg/raft/fsm.go Normal file
View file

@ -0,0 +1,99 @@
package raft
import (
"fmt"
"io"
"time"
"git.giftfish.de/ston1th/raftbbolt/msgpack"
"github.com/hashicorp/raft"
)
const (
retainSnapshotCount = 2
raftTimeout = 10 * time.Second
)
type Op uint8
const (
SET Op = iota
DELETE
)
type raftCmd struct {
Op Op
K string
V []byte
}
type fsm kv
func (f *fsm) Apply(l *raft.Log) interface{} {
var c raftCmd
if err := msgpack.Unmarshal(l.Data, &c); err != nil {
panic(fmt.Sprintf("failed to unmarshal command: %s", err.Error()))
}
switch c.Op {
case SET:
f.applySet(c.K, c.V)
case DELETE:
f.applyDelete(c.K)
default:
panic(fmt.Sprintf("unrecognized command op: %d", c.Op))
}
return nil
}
func (f *fsm) Snapshot() (raft.FSMSnapshot, error) {
f.mu.Lock()
defer f.mu.Unlock()
m := make(kvm)
for k, v := range f.m {
m[k] = v
}
return &fsmSnapshot{m: m}, nil
}
func (f *fsm) Restore(rc io.ReadCloser) error {
o := make(kvm)
if err := msgpack.NewDecoder(rc).Decode(&o); err != nil {
return err
}
f.m = o
return nil
}
func (f *fsm) applySet(k string, v []byte) {
f.mu.Lock()
f.m[k] = v
f.mu.Unlock()
}
func (f *fsm) applyDelete(k string) {
f.mu.Lock()
delete(f.m, k)
f.mu.Unlock()
}
type fsmSnapshot struct {
m kvm
}
func (f *fsmSnapshot) Persist(sink raft.SnapshotSink) error {
err := func() error {
err := msgpack.NewEncoder(sink).Encode(f.m)
if err != nil {
return err
}
return sink.Close()
}()
if err != nil {
sink.Cancel()
}
return err
}
func (f *fsmSnapshot) Release() {}

56
pkg/raft/kv.go Normal file
View file

@ -0,0 +1,56 @@
package raft
import (
"errors"
"sync"
"git.giftfish.de/ston1th/raftbbolt/msgpack"
"github.com/hashicorp/raft"
)
var ErrNotLeader = errors.New("not leader")
type kvm map[string][]byte
type kv struct {
mu sync.Mutex
m kvm
r *raft.Raft
}
func (kv *kv) Get(k string) ([]byte, error) {
kv.mu.Lock()
defer kv.mu.Unlock()
return kv.m[k], nil
}
func (kv *kv) Set(k string, v []byte) error {
if kv.r.State() != raft.Leader {
return ErrNotLeader
}
c := &raftCmd{SET, k, v}
b, err := msgpack.Marshal(c)
if err != nil {
return err
}
f := kv.r.Apply(b, raftTimeout)
return f.Error()
}
// Delete deletes the given key.
func (kv *kv) Delete(k string) error {
if kv.r.State() != raft.Leader {
return ErrNotLeader
}
c := &raftCmd{Op: DELETE, K: k}
b, err := msgpack.Marshal(c)
if err != nil {
return err
}
f := kv.r.Apply(b, raftTimeout)
return f.Error()
}

202
pkg/raft/raft.go Normal file
View file

@ -0,0 +1,202 @@
package raft
import (
"context"
"fmt"
"net"
"os"
"path/filepath"
"time"
"git.giftfish.de/ston1th/raftbbolt"
"git.giftfish.de/ston1th/vipman/pkg/config"
"git.giftfish.de/ston1th/vipman/pkg/raft/tls"
"git.giftfish.de/ston1th/vipman/pkg/util"
hclog "github.com/hashicorp/go-hclog"
"github.com/hashicorp/raft"
)
func (c *Cluster) Start(raftcfg *config.Config) error {
err := c.checkConfig()
if err != nil {
return err
}
cfg := raftcfg.Cluster.Raft
hcl := hclog.New(&hclog.LoggerOptions{
Name: "raft",
Level: hclog.LevelFromString(cfg.LogLevel),
Output: hclog.DefaultOutput,
TimeFormat: "I0102 15:04:05.000000",
})
rc := raft.DefaultConfig()
rc.ProtocolVersion = 3
rc.LocalID = raft.ServerID(cfg.ID)
rc.Logger = hcl
c.id = cfg.ID
pool := 3
timeout := 10 * time.Second
var transport *raft.NetworkTransport
if cfg.TLS != nil {
c.V(1).Info("setup", "transport", "tls")
s, err := tls.NewStream(cfg)
if err != nil {
return err
}
transport = raft.NewNetworkTransportWithLogger(s, pool, timeout, hcl)
} else {
c.V(1).Info("setup", "transport", "tcp")
address, err := net.ResolveTCPAddr("tcp", cfg.Address)
if err != nil {
return err
}
transport, err = raft.NewTCPTransportWithLogger(cfg.Address, address, pool, timeout, hcl)
if err != nil {
return err
}
}
// Create Raft structures
//snapshots := raft.NewInmemSnapshotStore()
snapshots, err := raft.NewFileSnapshotStoreWithLogger(cfg.Dir, retainSnapshotCount, hcl)
if err != nil {
return fmt.Errorf("file snapshot store: %s", err)
}
store := filepath.Join(cfg.Dir, "store.db")
//logStore := raft.NewInmemStore()
bootstrap := true
if _, err := os.Stat(store); err == nil {
bootstrap = false
}
stableStore, err := raftbbolt.NewStore(store)
if err != nil {
return err
}
logStore := stableStore
// Cluster configuration
configuration := raft.Configuration{}
// Add Local Peer
configuration.Servers = append(configuration.Servers, raft.Server{
ID: raft.ServerID(cfg.ID),
Address: raft.ServerAddress(cfg.Address),
})
for _, p := range cfg.Peers {
if cfg.Address != p.Address {
configuration.Servers = append(configuration.Servers, raft.Server{
ID: raft.ServerID(p.ID),
Address: raft.ServerAddress(p.Address)})
}
}
if bootstrap {
if err := raft.BootstrapCluster(rc, logStore, stableStore, snapshots, transport, configuration); err != nil {
return err
}
}
c.r, err = raft.NewRaft(rc, (*fsm)(c.kvm), logStore, stableStore, snapshots, transport)
if err != nil {
return err
}
c.kvm.r = c.r
t := time.NewTicker(time.Second)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
leading := false
following := false
for {
if !leading && cfg.Address == string(c.r.Leader()) {
leading = true
go c.leader(ctx)
} else if !following && !leading {
following = true
go c.follower(ctx)
}
select {
case <-t.C:
case leader := <-c.r.LeaderCh():
if leader {
if following {
cancel()
following = false
ctx, cancel = context.WithCancel(context.Background())
}
if !leading {
leading = true
go c.leader(ctx)
}
} else if c.r.State() == raft.Follower {
if leading {
cancel()
leading = false
ctx, cancel = context.WithCancel(context.Background())
}
if !following {
following = true
go c.follower(ctx)
}
}
case <-c.stepdown:
if leading {
cancel()
c.r.LeadershipTransfer().Error()
}
case <-c.stop:
t.Stop()
cancel()
if leading {
c.r.LeadershipTransfer().Error()
}
if c.callbacks.Cleanup != nil {
ctx, _ := context.WithTimeout(context.Background(), time.Second*2)
c.callbacks.Cleanup(ctx, c)
}
close(c.done)
return nil
}
}
return nil
}
func (c *Cluster) leader(ctx context.Context) {
defer func() {
util.HandleCrash()
}()
c.callbacks.Leader(ctx, c)
}
func (c *Cluster) follower(ctx context.Context) {
defer func() {
util.HandleCrash()
}()
c.callbacks.Follower(ctx, c)
}
func (c *Cluster) Stepdown() {
select {
case c.stepdown <- struct{}{}:
case <-time.After(time.Second * 2):
}
}
func (c *Cluster) Stop() {
close(c.stop)
<-c.done
s := make(chan struct{})
go func() {
c.r.Shutdown().Error()
close(s)
}()
select {
case <-s:
case <-time.After(time.Second * 5):
}
}

63
pkg/raft/tls/stream.go Normal file
View file

@ -0,0 +1,63 @@
package tls
import (
"crypto/tls"
"crypto/x509"
"net"
"time"
"git.giftfish.de/ston1th/vipman/pkg/config"
"github.com/hashicorp/raft"
)
var (
ciphers = []uint16{
tls.TLS_CHACHA20_POLY1305_SHA256,
}
curves = []tls.CurveID{
tls.X25519,
}
)
type stream struct {
net.Listener
insecure bool
ca *x509.CertPool
}
func NewStream(cfg *config.Raft) (s *stream, err error) {
cert, err := tls.LoadX509KeyPair(cfg.TLS.Cert, cfg.TLS.Key)
if err != nil {
return
}
ca, err := config.LoadCertPool(cfg.TLS.CA)
if err != nil {
return
}
l, err := tls.Listen("tcp", cfg.Address, &tls.Config{
MinVersion: tls.VersionTLS13,
CipherSuites: ciphers,
CurvePreferences: curves,
PreferServerCipherSuites: true,
Certificates: []tls.Certificate{cert},
})
if err != nil {
return
}
s = &stream{l, cfg.TLS.Insecure, ca}
return
}
func (s *stream) Dial(address raft.ServerAddress, timeout time.Duration) (net.Conn, error) {
return tls.DialWithDialer(
&net.Dialer{Timeout: timeout},
"tcp", string(address),
&tls.Config{
MinVersion: tls.VersionTLS13,
CipherSuites: ciphers,
CurvePreferences: curves,
InsecureSkipVerify: s.insecure,
RootCAs: s.ca,
},
)
}

41
pkg/util/crash.go Normal file
View file

@ -0,0 +1,41 @@
package util
import (
"net/http"
"runtime"
"k8s.io/klog/v2"
)
var reallyCrash = true
func HandleCrash() {
if r := recover(); r != nil {
logPanic(r)
if reallyCrash {
panic(r)
}
}
}
// logPanic logs the caller tree when a panic occurs (except in the special case of http.ErrAbortHandler).
func logPanic(r interface{}) {
if r == http.ErrAbortHandler {
// honor the http.ErrAbortHandler sentinel panic value:
// ErrAbortHandler is a sentinel panic value to abort a handler.
// While any panic from ServeHTTP aborts the response to the client,
// panicking with ErrAbortHandler also suppresses logging of a stack trace to the server's error log.
return
}
// Same as stdlib http server code. Manually allocate stack trace buffer size
// to prevent excessively large logs
const size = 64 << 10
stacktrace := make([]byte, size)
stacktrace = stacktrace[:runtime.Stack(stacktrace, false)]
if _, ok := r.(string); ok {
klog.Errorf("Observed a panic: %s\n%s", r, stacktrace)
} else {
klog.Errorf("Observed a panic: %#v (%v)\n%s", r, r, stacktrace)
}
}

10
pkg/vip/arp.go Normal file
View file

@ -0,0 +1,10 @@
// +build !linux
package vip
import "fmt"
// ARPSendGratuitous is only supported on Linux, so return an error
func ARPSendGratuitous(cidr, iface string) error {
return fmt.Errorf("Unsupported on this OS")
}

173
pkg/vip/arp_linux.go.bak Normal file
View file

@ -0,0 +1,173 @@
// +build linux
// These syscalls are only supported on Linux, so this uses a build directive during compilation. Other OS's will use the arp_unsupported.go and receive an error
package vip
import (
"bytes"
"encoding/binary"
"fmt"
"net"
"syscall"
"unsafe"
)
const (
opARPRequest = 1
opARPReply = 2
hwLen = 6
)
var (
ethernetBroadcast = net.HardwareAddr{0xff, 0xff, 0xff, 0xff, 0xff, 0xff}
// arpRequest is used to flip between garp request or garp reply
arpRequest = true
)
func htons(p uint16) uint16 {
var b [2]byte
binary.BigEndian.PutUint16(b[:], p)
return *(*uint16)(unsafe.Pointer(&b))
}
// arpHeader specifies the header for an ARP message.
type arpHeader struct {
hardwareType uint16
protocolType uint16
hardwareAddressLength uint8
protocolAddressLength uint8
opcode uint16
}
// arpMessage represents an ARP message.
type arpMessage struct {
arpHeader
senderHardwareAddress []byte
senderProtocolAddress []byte
targetHardwareAddress []byte
targetProtocolAddress []byte
}
// bytes returns the wire representation of the ARP message.
func (m *arpMessage) bytes() ([]byte, error) {
buf := new(bytes.Buffer)
if err := binary.Write(buf, binary.BigEndian, m.arpHeader); err != nil {
return nil, fmt.Errorf("binary write failed: %v", err)
}
buf.Write(m.senderHardwareAddress)
buf.Write(m.senderProtocolAddress)
buf.Write(m.targetHardwareAddress)
buf.Write(m.targetProtocolAddress)
return buf.Bytes(), nil
}
// gratuitousARP return a gARP request or gARP reply alternatively
// because different devices may support either one of them
func gratuitousARP(ip net.IP, mac net.HardwareAddr) (*arpMessage, error) {
if ip.To4() == nil {
return nil, fmt.Errorf("%q is not an IPv4 address", ip)
}
if len(mac) != hwLen {
return nil, fmt.Errorf("%q is not an Ethernet MAC address", mac)
}
m := &arpMessage{
arpHeader: arpHeader{
1, // Ethernet
0x0800, // IPv4
hwLen, // 48-bit MAC Address
net.IPv4len, // 32-bit IPv4 Address
opARPReply, // ARP Reply
},
}
// https://tools.ietf.org/html/rfc5944#section-4.6
// In either case, the ARP Sender Hardware Address is
// set to the link-layer address to which this cache entry should be
// updated.
m.senderHardwareAddress = mac
// When using an ARP Reply packet, the Target Hardware
// Address is also set to the link-layer address to which this cache
// entry should be updated (this field is not used in an ARP Request
// packet).
m.targetHardwareAddress = mac
// In either case, the ARP Sender Protocol Address and
// ARP Target Protocol Address are both set to the IP address of the
// cache entry to be updated,
m.senderProtocolAddress = ip.To4()
m.targetProtocolAddress = ip.To4()
// send arpRequest and arpReply alternatively
arpRequest = !arpRequest
if arpRequest {
m.arpHeader.opcode = opARPRequest
// this field is not used in an ARP Request packet
m.targetHardwareAddress = ethernetBroadcast
}
return m, nil
}
// sendARP sends the given ARP message via the specified interface.
func sendARP(iface *net.Interface, m *arpMessage) error {
fd, err := syscall.Socket(syscall.AF_PACKET, syscall.SOCK_DGRAM, int(htons(syscall.ETH_P_ARP)))
if err != nil {
return fmt.Errorf("failed to get raw socket: %v", err)
}
defer syscall.Close(fd)
if err := syscall.BindToDevice(fd, iface.Name); err != nil {
return fmt.Errorf("failed to bind to device: %v", err)
}
ll := syscall.SockaddrLinklayer{
Protocol: htons(syscall.ETH_P_ARP),
Ifindex: iface.Index,
Pkttype: 0, // syscall.PACKET_HOST
Hatype: m.hardwareType,
Halen: m.hardwareAddressLength,
}
target := ethernetBroadcast
for i := 0; i < len(target); i++ {
ll.Addr[i] = target[i]
}
b, err := m.bytes()
if err != nil {
return fmt.Errorf("failed to convert ARP message: %v", err)
}
if err := syscall.Bind(fd, &ll); err != nil {
return fmt.Errorf("failed to bind: %v", err)
}
if err := syscall.Sendto(fd, b, 0, &ll); err != nil {
return fmt.Errorf("failed to send: %v", err)
}
return nil
}
// ARPSendGratuitous sends a gratuitous ARP message via the specified interface.
func ARPSendGratuitous(cidr, iface string) error {
i, err := net.InterfaceByName(iface)
if err != nil {
return fmt.Errorf("failed to get interface %q: %v", iface, err)
}
ip, _, err := net.ParseCIDR(cidr)
if err != nil {
return fmt.Errorf("failed to parse cidr: %s", cidr)
}
//log.Infof("Broadcasting ARP update for %s (%s) via %s", address, iface.HardwareAddr, iface.Name)
m, err := gratuitousARP(ip, i.HardwareAddr)
if err != nil {
return err
}
return sendARP(i, m)
}

38
pkg/vip/arp_linux2.go Normal file
View file

@ -0,0 +1,38 @@
package vip
import (
"fmt"
"net"
"github.com/mdlayher/arp"
"github.com/mdlayher/ethernet"
)
func sendARP(ip net.IP, iface *net.Interface) error {
c, err := arp.Dial(iface)
if err != nil {
return err
}
for _, op := range []arp.Operation{arp.OperationRequest, arp.OperationReply} {
pkt, err := arp.NewPacket(op, iface.HardwareAddr, ip, ethernet.Broadcast, ip)
if err != nil {
return fmt.Errorf("assembling %q gratuitous packet for %q: %s", op, ip, err)
}
if err = c.WriteTo(pkt, ethernet.Broadcast); err != nil {
return fmt.Errorf("writing %q gratuitous packet for %q: %s", op, ip, err)
}
}
return nil
}
func ARPSendGratuitous(cidr, iface string) error {
i, err := net.InterfaceByName(iface)
if err != nil {
return fmt.Errorf("failed to get interface %q: %v", iface, err)
}
ip, _, err := net.ParseCIDR(cidr)
if err != nil {
return fmt.Errorf("failed to parse cidr: %s", cidr)
}
return sendARP(ip, i)
}

96
pkg/vip/vip.go Normal file
View file

@ -0,0 +1,96 @@
package vip
import (
"errors"
"github.com/vishvananda/netlink"
"net"
)
var ErrNoDefaultInterface = errors.New("no default interface found")
func DefaultInterface() (iface string, err error) {
routes, err := netlink.RouteGet(net.IPv4bcast)
if err != nil {
return
}
if len(routes) >= 1 {
link, err := netlink.LinkByIndex(routes[0].LinkIndex)
if err != nil {
return "", err
}
return link.Attrs().Name, nil
}
err = ErrNoDefaultInterface
return
}
type Network struct {
iface string
label string
link netlink.Link
}
func NewNetwork(iface string) (n *Network, err error) {
return NewNetworkWithLabel(iface, "")
}
func NewNetworkWithLabel(iface, label string) (n *Network, err error) {
if iface == "" {
iface, err = DefaultInterface()
if err != nil {
return
}
}
link, err := netlink.LinkByName(iface)
if err != nil {
return
}
n = &Network{iface, label, link}
return
}
func (n *Network) Interface() string {
return n.iface
}
func (n *Network) HasIP(a *netlink.Addr) bool {
addrs, err := netlink.AddrList(n.link, 0)
if err != nil {
return false
}
for _, addr := range addrs {
if addr.Equal(*a) {
return true
}
}
return false
}
func (n *Network) AddIP(cidr string) error {
addr, err := netlink.ParseAddr(cidr)
if err != nil {
return err
}
if n.HasIP(addr) {
return nil
}
if n.label != "" {
addr.Label = n.iface + ":" + n.label
}
err = netlink.AddrReplace(n.link, addr)
if err != nil {
return err
}
return ARPSendGratuitous(cidr, n.iface)
}
func (n *Network) DeleteIP(cidr string) error {
addr, err := netlink.ParseAddr(cidr)
if err != nil {
return err
}
if !n.HasIP(addr) {
return nil
}
return netlink.AddrDel(n.link, addr)
}