HAProxy Data Plane API
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

623 lines
18 KiB

// Copyright 2019 HAProxy Technologies
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
package configuration
import (
"bytes"
"context"
"crypto/rand"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/asn1"
"encoding/pem"
"fmt"
"io"
"net/http"
"path"
"strconv"
"strings"
"time"
"github.com/google/renameio"
client_native "github.com/haproxytech/client-native/v6"
"github.com/haproxytech/config-parser/v5/types"
jsoniter "github.com/json-iterator/go"
"github.com/haproxytech/dataplaneapi/log"
"github.com/haproxytech/dataplaneapi/haproxy"
"github.com/haproxytech/dataplaneapi/misc"
)
const DataplaneAPIType = "community"
// Node is structure required for connection to cluster
type Node struct {
Facts map[string]string `json:"facts"`
Address string `json:"address"`
APIBasePath string `json:"api_base_path"`
APIPassword string `json:"api_password"`
APIUser string `json:"api_user"`
Certificate string `json:"certificate,omitempty"`
Description string `json:"description,omitempty"`
ID string `json:"id,omitempty"`
Name string `json:"name"`
Status string `json:"status"`
Type string `json:"type"`
Port int64 `json:"port,omitempty"`
}
// ClusterSync fetches certificates for joining cluster
type ClusterSync struct {
cfg *Configuration
certFetch chan struct{}
cli client_native.HAProxyClient
Context context.Context
ReloadAgent haproxy.IReloadAgent
}
var expectedResponseCodes = map[string]int{
"POST": 201,
"PUT": 200,
}
func (c *ClusterSync) Monitor(cfg *Configuration, cli client_native.HAProxyClient) {
c.cfg = cfg
c.cli = cli
go c.monitorBootstrapKey()
go c.monitorCertificateRefresh()
c.certFetch = make(chan struct{}, 2)
go c.fetchCert()
<-c.cfg.Notify.ServerStarted.Subscribe("clusterMonitor")
key := c.cfg.Cluster.BootstrapKey.Load()
certFetched := cfg.Cluster.CertificateFetched.Load()
if key != "" && !certFetched {
c.cfg.Notify.BootstrapKeyChanged.Notify()
}
}
func (c *ClusterSync) monitorCertificateRefresh() {
for {
select {
case <-c.cfg.Notify.CertificateRefresh.Subscribe("monitorCertificateRefresh"):
log.Info("refreshing certificate")
key := c.cfg.Cluster.BootstrapKey.Load()
data, err := DecodeBootstrapKey(key)
if err != nil {
log.Warning(err)
continue
}
url := fmt.Sprintf("%s://%s", data["schema"], data["address"])
csr, key, err := generateCSR()
if err != nil {
log.Warning(err)
continue
}
err = renameio.WriteFile(path.Join(c.cfg.GetClusterCertDir(), fmt.Sprintf("dataplane-%s-csr.crt", c.cfg.Name.Load())), []byte(csr), 0o644)
if err != nil {
log.Warning(err)
continue
}
err = c.issueRefreshRequest(url, data["port"], data["api-base-path"], data["path"], csr, key)
if err != nil {
log.Warning(err)
continue
}
case <-c.Context.Done():
return
}
}
}
func (c *ClusterSync) issueRefreshRequest(url, port, basePath string, nodesPath string, csr, key string) error {
url = fmt.Sprintf("%s:%s/%s", url, port, strings.TrimLeft(path.Join(basePath, nodesPath, c.cfg.Cluster.ID.Load()), "/"))
apiAddress := c.cfg.APIOptions.APIAddress
if apiAddress == "" {
apiAddress = c.cfg.RuntimeData.Host
}
nodeData := Node{
ID: c.cfg.Cluster.ID.Load(),
Address: apiAddress,
Certificate: csr,
Status: cfg.Status.Load(),
Type: DataplaneAPIType,
}
json := jsoniter.ConfigCompatibleWithStandardLibrary
bytesRepresentation, _ := json.Marshal(nodeData)
req, err := http.NewRequest(http.MethodPatch, url, bytes.NewBuffer(bytesRepresentation))
if err != nil {
return fmt.Errorf("error creating new POST request for cluster comunication")
}
req.Header.Add("X-Node-Key", c.cfg.Cluster.Token.Load())
req.Header.Add("Content-Type", "application/json")
log.Infof("Refreshing certificate %s", url)
httpClient := createHTTPClient()
resp, err := httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
if resp.StatusCode != http.StatusAccepted {
return fmt.Errorf("status code not proper [%d] %s", resp.StatusCode, string(body))
}
var responseData Node
err = json.Unmarshal(body, &responseData)
if err != nil {
return err
}
log.Infof("Cluster re joined, status: %s", responseData.Status)
err = renameio.WriteFile(path.Join(c.cfg.GetClusterCertDir(), fmt.Sprintf("dataplane-%s.crt", c.cfg.Name.Load())), []byte(csr), 0o644)
if err != nil {
log.Warning(err)
return err
}
err = renameio.WriteFile(path.Join(c.cfg.GetClusterCertDir(), fmt.Sprintf("dataplane-%s.key", c.cfg.Name.Load())), []byte(key), 0o644)
if err != nil {
log.Warning(err)
return err
}
c.cfg.Cluster.Token.Store(resp.Header.Get("X-Node-Key"))
err = c.cfg.Save()
if err != nil {
log.Warning(err)
return err
}
c.cfg.Notify.Reload.Notify()
return nil
}
func (c *ClusterSync) monitorBootstrapKey() {
for {
select {
case <-c.cfg.Notify.BootstrapKeyChanged.Subscribe("monitorBootstrapKey"):
log.Warningf("detected change in configured bootstrap key")
key := c.cfg.Cluster.BootstrapKey.Load()
c.cfg.Cluster.CertificateFetched.Store(false)
if key == "" {
// do we need to delete cert here maybe?
log.Warningf("setting bootstrap key to empty")
c.cfg.Cluster.ActiveBootstrapKey.Store("")
err := c.cfg.Save()
if err != nil {
log.Panic(err)
}
break
}
if key == c.cfg.Cluster.ActiveBootstrapKey.Load() {
if !c.cfg.Cluster.CertificateFetched.Load() {
c.certFetch <- struct{}{}
}
break
}
data, err := DecodeBootstrapKey(key)
if err != nil {
log.Warning(err)
}
// check if storage key is ok
errStorageDir := CheckIfStorageDirIsOK(data["storage-dir"], c.cfg)
if errStorageDir != err {
log.Error(errStorageDir)
continue
}
// Init NOTICE file to inform user that the cluster storage folder is programmatically managed by Fusion API
if errStorageInit := InitStorageNoticeFile(data["storage-dir"]); errStorageInit != nil {
log.Warningf("unable to create notice file, %s: skipping it", errStorageInit.Error())
}
url := fmt.Sprintf("%s://%s", data["schema"], data["address"])
c.cfg.Cluster.URL.Store(url)
c.cfg.Cluster.Port.Store(func() int {
i, _ := strconv.Atoi(data["port"])
return i
}())
c.cfg.Cluster.APIBasePath.Store(data["api-base-path"])
registerPath, ok := data["register-path"]
if !ok {
c.cfg.Cluster.APIRegisterPath.Store(data["path"])
c.cfg.Cluster.APINodesPath.Store(data["path"])
} else {
c.cfg.Cluster.APIRegisterPath.Store(registerPath)
c.cfg.Cluster.APINodesPath.Store(data["nodes-path"])
}
c.cfg.Cluster.Name.Store(data["name"])
c.cfg.Cluster.Description.Store(data["description"])
c.cfg.Cluster.StorageDir.Store(data["storage-dir"])
c.cfg.Cluster.ClusterID.Store(data["cluster-id"])
c.cfg.HAProxy.ClusterTLSCertDir = path.Join(data["storage-dir"], "certs-cluster")
c.cfg.Cluster.CertificateDir.Store(path.Join(data["storage-dir"], "certs-cluster"))
c.cfg.Mode.Store(ModeCluster)
err = c.cfg.Save()
if err != nil {
log.Panic(err)
}
csr, key, err := generateCSR()
if err != nil {
log.Warning(err)
break
}
err = renameio.WriteFile(path.Join(c.cfg.GetClusterCertDir(), fmt.Sprintf("dataplane-%s.key", c.cfg.Name.Load())), []byte(key), 0o644)
if err != nil {
log.Warning(err)
break
}
err = renameio.WriteFile(path.Join(c.cfg.GetClusterCertDir(), fmt.Sprintf("dataplane-%s-csr.crt", c.cfg.Name.Load())), []byte(csr), 0o644)
if err != nil {
log.Warning(err)
break
}
err = c.cfg.Save()
if err != nil {
log.Panic(err)
}
registerMerhod := "POST"
if method, ok := data["register-method"]; ok {
registerMerhod = method
}
log.Warningf("issuing cluster join request to cluster %s at %s", data["name"], data["address"])
userStore := GetUsersStore()
user, pwd, err := misc.CreateClusterUser()
if err != nil {
log.Error(err)
break
}
err = userStore.AddUser(user)
if err != nil {
log.Error(err)
break
}
backOff := 1
numTries := 0
maxTries := 10
for {
err = c.issueJoinRequest(url, data["port"], data["api-base-path"], c.cfg.Cluster.APIRegisterPath.Load(), registerMerhod, csr, key, user, pwd)
if err == nil {
break
}
log.Error(err)
if !misc.IsNetworkErr(err) {
break
}
numTries++
backOff *= 2
if backOff > 60 {
backOff = 60
}
if numTries > maxTries {
log.Error("Joining cluster failed")
break
}
log.Warningf("Joining cluster will be retried after %d seconds [%d/%d]", backOff, numTries, maxTries)
time.Sleep(time.Second * time.Duration(backOff))
}
if err != nil {
break
}
if !c.cfg.Cluster.CertificateFetched.Load() {
log.Warningf("starting certificate fetch")
c.certFetch <- struct{}{}
}
case <-c.Context.Done():
return
}
}
}
func (c *ClusterSync) issueJoinRequest(url, port, basePath string, registerPath string, registerMethod string, csr, key string, user types.User, userPWD string) error {
url = fmt.Sprintf("%s:%s/%s", url, port, strings.TrimLeft(path.Join(basePath, registerPath), "/"))
apiCfg := c.cfg.APIOptions
apiAddress := apiCfg.APIAddress
if apiAddress == "" {
apiAddress = c.cfg.RuntimeData.Host
}
apiPort := apiCfg.APIPort
if apiPort == 0 {
apiPort = int64(c.cfg.RuntimeData.Port)
}
nodeData := Node{
// ID: "",
Address: apiAddress,
APIBasePath: c.cfg.RuntimeData.APIBasePath,
APIPassword: userPWD,
APIUser: user.Name,
Certificate: csr,
Description: "",
Name: c.cfg.Name.Load(),
Port: apiPort,
Status: "waiting_approval",
Type: DataplaneAPIType,
}
nodeData.Facts = c.getNodeFacts()
json := jsoniter.ConfigCompatibleWithStandardLibrary
bytesRepresentation, _ := json.Marshal(nodeData)
req, err := http.NewRequest(registerMethod, url, bytes.NewBuffer(bytesRepresentation))
if err != nil {
return fmt.Errorf("error creating new %s request for cluster comunication", registerMethod)
}
req.Header.Add("X-Bootstrap-Key", c.cfg.Cluster.BootstrapKey.Load())
req.Header.Add("Content-Type", "application/json")
log.Infof("Joining cluster %s", url)
httpClient := createHTTPClient()
resp, err := httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
if resp.StatusCode != expectedResponseCodes[registerMethod] {
return fmt.Errorf("invalid status code [%d] %s", resp.StatusCode, string(body))
}
log.Warningf("success sending local info, joining in progress")
var responseData Node
err = json.Unmarshal(body, &responseData)
if err != nil {
return err
}
if c.cfg.HAProxy.NodeIDFile != "" {
configuration, errCfg := c.cli.Configuration()
if errCfg != nil {
return errCfg
}
// write id to file
errFID := renameio.WriteFile(c.cfg.HAProxy.NodeIDFile, []byte(responseData.ID), 0o644)
if errFID != nil {
return errFID
}
version, errVersion := configuration.GetVersion("")
if errVersion != nil || version < 1 {
// silently fallback to 1
version = 1
}
t, err1 := configuration.StartTransaction(version)
if err1 != nil {
return err1
}
// write id to peers
_, peerSections, errorGet := configuration.GetPeerSections(t.ID)
if errorGet != nil {
return errorGet
}
peerFound := false
dataplaneID := c.cfg.Cluster.ID.Load()
if dataplaneID == "" {
dataplaneID = "localhost"
}
for _, section := range peerSections {
_, peerEntries, err1 := configuration.GetPeerEntries(section.Name, t.ID)
if err1 != nil {
return err1
}
for _, peer := range peerEntries {
if peer.Name == dataplaneID {
peerFound = true
peer.Name = responseData.ID
errEdit := configuration.EditPeerEntry(dataplaneID, section.Name, peer, t.ID, 0)
if errEdit != nil {
_ = configuration.DeleteTransaction(t.ID)
return err
}
}
}
}
if !peerFound {
_ = configuration.DeleteTransaction(t.ID)
return fmt.Errorf("peer [%s] not found in HAProxy config", dataplaneID)
}
_, err = configuration.CommitTransaction(t.ID)
if err != nil {
return err
}
// restart HAProxy
errRestart := c.ReloadAgent.Restart()
if errRestart != nil {
return errRestart
}
}
c.cfg.Cluster.ID.Store(responseData.ID)
c.cfg.Cluster.Name.Store(responseData.Name)
c.cfg.Cluster.Token.Store(resp.Header.Get("X-Node-Key"))
c.cfg.Cluster.ActiveBootstrapKey.Store(c.cfg.Cluster.BootstrapKey.Load())
log.Warning("cluster joined")
_, err = c.checkCertificate(responseData)
if err != nil {
return err
}
return c.cfg.Save()
}
// checkCertificate checks if we have received valid certificate or we just got CSR back
//
// two options are possible here:
// -----BEGIN CERTIFICATE----- or -----BEGIN CERTIFICATE REQUEST-----
func (c *ClusterSync) checkCertificate(node Node) (fetched bool, err error) {
if !strings.HasPrefix(node.Certificate, "-----BEGIN CERTIFICATE-----") {
c.cfg.Status.Store("unconfigured")
return false, nil
}
err = renameio.WriteFile(path.Join(c.cfg.GetClusterCertDir(), fmt.Sprintf("dataplane-%s.crt", c.cfg.Name.Load())), []byte(node.Certificate), 0o644)
if err != nil {
c.cfg.Status.Store("unconfigured")
return false, err
}
c.cfg.Cluster.CertificateFetched.Store(true)
c.cfg.Notify.Reload.Notify()
c.cfg.Status.Store("active")
return true, nil
}
func (c *ClusterSync) activateFetchCert(err error) {
go func(err error) {
log.Warning(err)
time.Sleep(1 * time.Minute)
if !c.cfg.Cluster.CertificateFetched.Load() {
c.certFetch <- struct{}{}
}
}(err)
}
func (c *ClusterSync) fetchCert() {
for {
select {
case <-c.Context.Done():
close(c.certFetch)
return
case <-c.certFetch:
key := c.cfg.Cluster.BootstrapKey.Load()
if key == "" || c.cfg.Cluster.Token.Load() == "" {
break
}
// if not, sleep and start all over again
certFetched := c.cfg.Cluster.CertificateFetched.Load()
if !certFetched {
url := c.cfg.Cluster.URL.Load()
port := c.cfg.Cluster.Port.Load()
apiBasePath := c.cfg.Cluster.APIBasePath.Load()
apiNodesPath := c.cfg.Cluster.APINodesPath.Load()
id := c.cfg.Cluster.ID.Load()
url = fmt.Sprintf("%s:%d/%s", url, port, strings.TrimLeft(path.Join(apiBasePath, apiNodesPath, id), "/"))
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
c.activateFetchCert(err)
break
}
req.Header.Add("X-Node-Key", c.cfg.Cluster.Token.Load())
req.Header.Add("Content-Type", "application/json")
httpClient := createHTTPClient()
resp, err := httpClient.Do(req)
if err != nil {
c.activateFetchCert(err)
break
}
body, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
c.activateFetchCert(err)
break
}
if resp.StatusCode != http.StatusOK {
c.activateFetchCert(fmt.Errorf("status code not proper [%d] %s", resp.StatusCode, string(body)))
break
}
var responseData Node
json := jsoniter.ConfigCompatibleWithStandardLibrary
err = json.Unmarshal(body, &responseData)
if err != nil {
c.activateFetchCert(err)
break
}
log.Warningf("Fetching certificate, status: %s", responseData.Status)
certFetched, err = c.checkCertificate(responseData)
if err != nil {
log.Warning(err.Error())
break
}
err = c.cfg.Save()
if err != nil {
log.Warning(err)
}
}
if !certFetched {
time.AfterFunc(time.Minute, func() {
if !c.cfg.Cluster.CertificateFetched.Load() {
log.Warningf("retrying certificate fetch")
c.certFetch <- struct{}{}
}
})
}
}
}
}
func generateCSR() (string, string, error) {
keyBytes, _ := rsa.GenerateKey(rand.Reader, 2048)
oidEmailAddress := asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 9, 1}
emailAddress := "test@example.com"
subj := pkix.Name{
CommonName: "haproxy.com",
Country: []string{"US"},
Province: []string{""},
Locality: []string{"Waltham"},
Organization: []string{"HAProxy Technologies LLC"},
OrganizationalUnit: []string{"IT"},
ExtraNames: []pkix.AttributeTypeAndValue{
{
Type: oidEmailAddress,
Value: asn1.RawValue{
Tag: asn1.TagIA5String,
Bytes: []byte(emailAddress),
},
},
},
}
template := x509.CertificateRequest{
Subject: subj,
SignatureAlgorithm: x509.SHA256WithRSA,
}
csrBytes, _ := x509.CreateCertificateRequest(rand.Reader, &template, keyBytes)
var buf bytes.Buffer
err := pem.Encode(&buf, &pem.Block{Type: "CERTIFICATE REQUEST", Bytes: csrBytes})
if err != nil {
return "", "", err
}
caPrivKeyPEMBuff := new(bytes.Buffer)
err = pem.Encode(caPrivKeyPEMBuff, &pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: x509.MarshalPKCS1PrivateKey(keyBytes),
})
if err != nil {
return "", "", err
}
return buf.String(), caPrivKeyPEMBuff.String(), err
}
func createHTTPClient() *http.Client {
client := &http.Client{
Transport: &http.Transport{
MaxIdleConnsPerHost: 20,
TLSClientConfig: &tls.Config{
InsecureSkipVerify: true, // this is deliberate, might only have self signed certificate
},
},
Timeout: time.Duration(30) * time.Second,
}
return client
}