From 7465b7a1e7a30f9621db7727c8c07fec9585e917 Mon Sep 17 00:00:00 2001 From: Igor Borisoglebski Date: Thu, 7 Dec 2023 11:11:33 +0000 Subject: [PATCH] Lobby v0.0.1 --- .github/workflows/docs.yaml | 29 + .github/workflows/test.yaml | 25 + .gitignore | 3 + CHANGELOG.md | 27 + LICENSE.md | 4 + Makefile | 75 ++ README.md | 43 + common.go | 204 ++++ common_test.go | 313 +++++ config_types.go | 52 + dns.go | 100 ++ dns_test.go | 53 + docs/content/assets/img/lobbyGopherLogo.png | Bin 0 -> 98449 bytes docs/examples/demo.conf | 49 + docs/examples/lobby.conf | 91 ++ docs/examples/lobby.service | 21 + docs/src/docs/CNAME | 1 + docs/src/docs/about.md | 11 + docs/src/docs/assets/lobbyConcepts.png | Bin 0 -> 46895 bytes docs/src/docs/assets/lobbySystemDiagram.gif | Bin 0 -> 29954 bytes docs/src/docs/assets/logo.png | Bin 0 -> 33765 bytes docs/src/docs/configuration.md | 180 +++ docs/src/docs/contacts.md | 3 + docs/src/docs/features.md | 187 +++ docs/src/docs/index.md | 133 ++ docs/src/docs/installation.md | 144 +++ docs/src/docs/stylesheets/extra.css | 5 + docs/src/docs/support.md | 3 + docs/src/docs/tutorials.md | 553 +++++++++ docs/src/mkdocs.yml | 66 + go.mod | 26 + go.sum | 46 + lb.go | 1207 +++++++++++++++++++ lb_norace_test.go | 167 +++ lb_test.go | 1117 +++++++++++++++++ logging.go | 88 ++ logging_test.go | 194 +++ main.go | 183 +++ main_norace_test.go | 82 ++ main_test.go | 107 ++ nftables.go | 1049 ++++++++++++++++ scripts/getLobby.sh | 160 +++ scripts/installLobby.sh | 257 ++++ scripts/uninstallLobby.sh | 50 + target.go | 16 + testEngine.go | 65 + upstream.go | 156 +++ upstream_test.go | 110 ++ 48 files changed, 7455 insertions(+) create mode 100644 .github/workflows/docs.yaml create mode 100644 .github/workflows/test.yaml create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 LICENSE.md create mode 100644 Makefile create mode 100644 README.md create mode 100644 common.go create mode 100644 common_test.go create mode 100644 config_types.go create mode 100644 dns.go create mode 100644 dns_test.go create mode 100644 docs/content/assets/img/lobbyGopherLogo.png create mode 100644 docs/examples/demo.conf create mode 100644 docs/examples/lobby.conf create mode 100644 docs/examples/lobby.service create mode 100644 docs/src/docs/CNAME create mode 100644 docs/src/docs/about.md create mode 100644 docs/src/docs/assets/lobbyConcepts.png create mode 100644 docs/src/docs/assets/lobbySystemDiagram.gif create mode 100644 docs/src/docs/assets/logo.png create mode 100644 docs/src/docs/configuration.md create mode 100644 docs/src/docs/contacts.md create mode 100644 docs/src/docs/features.md create mode 100644 docs/src/docs/index.md create mode 100644 docs/src/docs/installation.md create mode 100644 docs/src/docs/stylesheets/extra.css create mode 100644 docs/src/docs/support.md create mode 100644 docs/src/docs/tutorials.md create mode 100644 docs/src/mkdocs.yml create mode 100644 go.mod create mode 100644 go.sum create mode 100644 lb.go create mode 100644 lb_norace_test.go create mode 100644 lb_test.go create mode 100644 logging.go create mode 100644 logging_test.go create mode 100644 main.go create mode 100644 main_norace_test.go create mode 100644 main_test.go create mode 100644 nftables.go create mode 100644 scripts/getLobby.sh create mode 100644 scripts/installLobby.sh create mode 100644 scripts/uninstallLobby.sh create mode 100644 target.go create mode 100644 testEngine.go create mode 100644 upstream.go create mode 100644 upstream_test.go diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml new file mode 100644 index 0000000..ba333e8 --- /dev/null +++ b/.github/workflows/docs.yaml @@ -0,0 +1,29 @@ +name: docs +on: + push: + branches: + - main +permissions: + contents: write +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Configure Git Credentials + run: | + git config user.name github-actions[bot] + git config user.email 41898282+github-actions[bot]@users.noreply.github.com + - uses: actions/setup-python@v4 + with: + python-version: 3.x + - run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV + - uses: actions/cache@v3 + with: + key: mkdocs-material-${{ env.cache_id }} + path: .cache + restore-keys: | + mkdocs-material- + - run: pip install mkdocs-material mkdocs-awesome-pages-plugin + - run: mkdocs gh-deploy -f ./docs/src/mkdocs.yml --force + diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml new file mode 100644 index 0000000..fc4cf5f --- /dev/null +++ b/.github/workflows/test.yaml @@ -0,0 +1,25 @@ +name: test + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Setup go + uses: actions/setup-go@v4 + with: + go-version: '1.20' + + - name: Checkout code + uses: actions/checkout@v4 + + - name: Test with race check + run: make testIncCovRep + + - name: Test without race check + run: make testIncCovRepNoRace diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a166bfd --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +bin/ +*bkp +*out diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..a8663d2 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,27 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [0.0.1] - 2023-10-30 + +### Added + +- TCP Load Balancing +- round-robin distribution mode +- TCP upstream health check +- Upstream health check starts available/unavailable +- Configurable upstream health check timeout +- Configurable upstream health check interval +- Configurable upstream health check success count healthy threshold +- Upstream FQDN address +- Upstream FQDN resolution DNS list and DNS backup +- Upstream DNS TTL overwrite +- YAML file based config +- Hot config reload +- Basic metrics + diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..486416a --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,4 @@ +# Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License + +This work is licensed under [CC BY-NC-SA 4.0](http://creativecommons.org/licenses/by-nc-sa/4.0/). + diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..f3f600a --- /dev/null +++ b/Makefile @@ -0,0 +1,75 @@ +BINARY_DIR = bin +BINARY_NAME = lobby +RUN_ARCH = linux/amd64 + +# Go parameters +GOCMD = go +GOBUILD = $(GOCMD) build +GOTEST = $(GOCMD) test +GOGET = $(GOCMD) get +GOCLEAN = $(GOCMD) clean +GOTOOL = $(GOCMD) tool +GOCOVFN = coverage.out +CGO_ENABLED = 0 + +# Build flags and arguments +LDFLAGS = -ldflags="-s -w -X main.version=$(version)" +GCFLAGS = -gcflags="-m -l" +MODFLAGS = -mod=readonly +TRIMPATH = -trimpath + +# Cross-compilation target architectures +TARGETS = \ + linux/arm64 \ + linux/amd64 + +.PHONY: build clean run + +# check version is set +valver: + version := v$(shell grep -oE '\[[^]]+\]' CHANGELOG.md | sed -n '4{s/\[//;s/\]//p}') + ifeq ($(shell echo $(version) | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$$'),$(version)) + $(info Version '$(version)' is in the correct format) + else + $(error Version is not in the correct format. Check CHANGELOG.md) + endif + +# go get dependencies +get: + $(GOGET) . + +# Build for all the configured target architectures (TARGETS) +build: valver get $(TARGETS) + +$(TARGETS): + GOOS=$(word 1, $(subst /, ,$@)) GOARCH=$(word 2, $(subst /, ,$@)) CGO_ENABLED=$(CGO_ENABLED)\ + $(GOBUILD) $(LDFLAGS) $(GCFLAGS) $(MODFLAGS) $(TRIMPATH) \ + -o $(BINARY_DIR)/$(BINARY_NAME)-$(word 1, $(subst /, ,$@))-$(word 2, $(subst /, ,$@)) + +# Clean builds +clean: + rm -f $(BINARY_DIR)/$(BINARY_NAME)-* + +# Build and run +run: build + ./$(BINARY_DIR)/$(BINARY_NAME)-$(word 1, $(subst /, ,$(RUN_ARCH)))-$(word 2, $(subst /, ,$(RUN_ARCH))) + +# Includes race testing +test: + $(GOTEST) -cover -race -failfast + +# Includes race testing and coverage report +testIncCovRep: + $(GOCLEAN) -testcache + $(GOTEST) -v -cover -race -failfast -coverprofile=$(GOCOVFN) + @if [ $$? -eq 0 ]; then $(GOTOOL) cover -html=$(GOCOVFN); fi + rm -rf coverage.out + +# Excludes race testing as some testing functions fail on race tests (not the app) +# Higher unit test coverage with this option +testIncCovRepNoRace: + $(GOCLEAN) -testcache + $(GOTEST) -v -cover -failfast -coverprofile=$(GOCOVFN) + @if [ $$? -eq 0 ]; then $(GOTOOL) cover -html=$(GOCOVFN); fi + rm -rf coverage.out + diff --git a/README.md b/README.md new file mode 100644 index 0000000..4de2bdc --- /dev/null +++ b/README.md @@ -0,0 +1,43 @@ +

+ + Lobby + +

+ +**Lobby** is a simple, yet highly performant, load balancer based on Linux [nftables](https://wiki.nftables.org/wiki-nftables/) designed to run as a portable binary app on Linux systems in amd64 and arm64 architectures. + +# Quick Start +Use the helper script which downloads the latest Lobby binary to your current folder and sets a sample config file. + +``` bash +wget -O - https://ipbuff.com/getLobby | sh +``` + +Once the script completes, it will inform you about the required permissions and the need for IP forwarding to be enabled. + +For testing purposes it might be easier to run Lobby with the `root` user. Run Lobby with: + +``` bash +./lobby +``` + +To check on how to run it as unprivileged user check [here](https://lobby.ipbuff.com/installation/#permissions). + +To edit the Lobby settings, edit the `lobby.conf` file. Configuration documentation may be found [here](https://lobby.ipbuff.com/configuration). + +A relatively extensive tutorial can also be found in the Lobby documentation. Make sure to check it for many practical examples. + +# Documentation +The Lobby documentation is published at [https://lobby.ipbuff.com](https://lobby.ipbuff.com). + +The documentation source is in the [./docs/src](https://github.com/ipbuff/lobby/tree/main/docs/src) directory of this repository. + +# Credits +This project has been created by [Igor Borisoglebski](https://igor.borisoglebski.com). + +[Gopher Konstructor](https://quasilyte.dev/gopherkon/), created by [quasilyte](https://github.com/quasilyte/gopherkon) was used for the logo creation. + +# License +This work is licensed under [CC BY-NC-SA 4.0](http://creativecommons.org/licenses/by-nc-sa/4.0/). + +Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License diff --git a/common.go b/common.go new file mode 100644 index 0000000..f22283f --- /dev/null +++ b/common.go @@ -0,0 +1,204 @@ +package main + +import ( + "errors" + "fmt" + "net" + "os" + "regexp" + + "kernel.org/pub/linux/libs/security/libcap/cap" +) + +// settings +var ( + // Generic IPv4 ip_forward setting path + ipv4FPath = "/proc/sys/net/ipv4/ip_forward" + // IPv6 forwarding for all interfaces setting path + ipv6FPath = "/proc/sys/net/ipv6/conf/all/forwarding" +) + +// iota constants +type ( + hostType byte // host type + ipFwd byte // system IP forwarding state +) + +const ( + hostTypeUnknown hostType = iota // undefined + hostTypeIPv4 // host in IPv4 format + hostTypeIPv6 // host in IPv6 format + hostTypeFqdn // host in FQDN format +) + +const ( + ipFwdUnknown ipFwd = iota // undefined + ipFwdNone // no IP forwarding + ipFwdAll // IP forwarding enabled for IPv4 and IPv6 + ipFwdV4Only // IP forwarding enabled for IPv4 + ipFwdV6Only // IP forwarding enabled for IPv6 +) + +// Lookup patterns +const ( + // FQDN regex string according to RFC 1123 + fqdnRegexStringRFC1123 = `^([a-zA-Z0-9]{1}[a-zA-Z0-9-]{0,62})(\.[a-zA-Z0-9]{1}[a-zA-Z0-9-]{0,62})*?(\.[a-zA-Z]{1}[a-zA-Z0-9]{0,62})\.?$` // same as hostnameRegexStringRFC1123 but must contain a non numerical TLD (possibly ending with '.') +) + +// Pattern matches +var ( + // parses the fqdnRegexStringRFC1123 regex + fqdnRegexRFC1123 = regexp.MustCompile(fqdnRegexStringRFC1123) +) + +// Common errors +var ( + errCheckIpFwd = errors.New( + "IP Forwarding check failed", + ) + errReadIpv4Sett = errors.New( + "Couldn't read IPv4 forwarding control file", + ) + errReadIpv6Sett = errors.New( + "Couldn't read IPv6 forwarding control file", + ) + errCheckCap = errors.New( + "capabilities check failed", + ) + errHostType = errors.New( + "host type not found", + ) +) + +// Linux capabilities are a method to assign specific privileges to a running process +// This function checks if a Linux capability is set for a given process +// An error is returned in case it is not possible to perform the check or in case the capability check fails +func checkCapabilities(cs *cap.Set, vec cap.Flag, val cap.Value) error { + // check for process cs if val capabilities has the vec flags set + cf, err := cs.GetFlag(vec, val) + if err != nil { + return fmt.Errorf("%w: %w", errCheckCap, err) + } + + if !cf { + LogDVf("Check capabilities: failed") + return fmt.Errorf( + "%w: '%s' flag not set on '%s' capability", + errCheckCap, + vec, + val, + ) + } + + LogDVf("Check capabilities: succeeded") + return nil +} + +// checkIpFwd checks if IP forwarding is system enabled +// /proc/sys/net/ipv4/ip_forward is checked for IPv4 +// /proc/sys/net/ipv6/conf/all/forwarding for IPv6 +// IP forwarding is not checked for individual interfaces +// It is only checked if IP forwarding is generically enabled +// The function returns ipFwdUnkown in case of error +// An error is returned if the config files can't be read +// It returns: +// - ipFwdNone when IP forwarding is not enabled for IPv4 or IPv6 +// - ipFwdAll when IP forwading is enabled for IPv4 and IPv6 +// - ipFwdV4Only when IP forwarding is enabled for IPv4, but not for IPv6 +// - ipFwdV6Only when IP forwarding is not enabled for IPv4, but is enabled for IPv6 +func checkIpFwd() (ipFwd, error) { + LogDf("IP Fwd check: checking '%s' for IPv4 IP Forwarding", ipv4FPath) + ipv4f, err := os.ReadFile(ipv4FPath) + if err != nil { + return ipFwdUnknown, fmt.Errorf("%w: %w", errCheckIpFwd, errReadIpv4Sett) + } + + LogDf("IP Fwd check: checking '%s' for IPv6 IP Forwarding", ipv6FPath) + ipv6f, err := os.ReadFile(ipv6FPath) + if err != nil { + return ipFwdUnknown, fmt.Errorf("%w: %w", errCheckIpFwd, errReadIpv6Sett) + } + + // byte 49 is ASCII '1' + // file has content 0 when disabled + // file has content 1 when enabled + oneAscii := 49 + enabled := byte(oneAscii) + + if ipv4f[0] != enabled { + LogDf("IP Fwd check: IPv4 Forwarding is not enabled for all interfaces") + if ipv6f[0] != enabled { + LogDf("IP Fwd check: IPv6 Forwarding is not enabled for all interfaces") + return ipFwdNone, nil + } else { + LogDf("IP Fwd check: IPv6 Forwarding is enabled for all interfaces") + return ipFwdV6Only, nil + } + } + + LogDf("IP Fwd check: IPv4 Forwarding is enabled for all interfaces") + + if ipv6f[0] != enabled { + LogDf("IP Fwd check: IPv6 Forwarding is not enabled for all interfaces") + return ipFwdV4Only, nil + } + + LogDf("IP Fwd check: IPv6 Forwarding is enabled for all interfaces") + + return ipFwdAll, nil +} + +// getHostType returns a hostType from input string +func getHostType(host string) (hostType, error) { + ip := net.ParseIP(host) + if ip != nil { + if ip.To4() != nil { + return hostTypeIPv4, nil + } else if ip.To16() != nil { + return hostTypeIPv6, nil + } + } + + if isFqdn(host) { + return hostTypeFqdn, nil + } + + return hostTypeUnknown, fmt.Errorf("'%s' '%w'", host, errHostType) +} + +// isFqdn checks if input string is FQDN and returns boolean result +func isFqdn(s string) bool { + return fqdnRegexRFC1123.MatchString(s) +} + +// findUniqueNetIp returns a list of unique net.IP +func findUniqueNetIp(nipl *[]net.IP) []net.IP { + unipl := make([]net.IP, 0) + +niplLoop: + for _, nip := range *nipl { + for _, uip := range unipl { + if uip.Equal(nip) { + continue niplLoop + } + } + unipl = append(unipl, nip) + } + + return unipl +} + +// findDuplicateNetIp returns a list of duplicate net.IP +func findDuplicateNetIp(nipl *[]net.IP) []net.IP { + dnipl := make([]net.IP, 0) + occurrenceCount := make(map[string]int) + + for _, nip := range *nipl { + occurrenceCount[nip.String()]++ + if occurrenceCount[nip.String()] == 2 { + dnipl = append(dnipl, nip) + } + } + + return dnipl +} diff --git a/common_test.go b/common_test.go new file mode 100644 index 0000000..5fec7e9 --- /dev/null +++ b/common_test.go @@ -0,0 +1,313 @@ +package main + +import ( + "errors" + "net" + "os" + "reflect" + "testing" + + "kernel.org/pub/linux/libs/security/libcap/cap" +) + +func TestCheckCapabilities_Failure(t *testing.T) { + cs := cap.NewSet() + + vna := cap.NET_ADMIN + fe := cap.Effective + + cs.SetFlag(fe, true, vna) + + err := checkCapabilities(cs, fe, vna) + if err != nil { + t.Errorf("expected succeeded: %v", err) + } +} + +func TestCheckCapabilities_Success(t *testing.T) { + cs := cap.NewSet() + + vna := cap.NET_ADMIN + fe := cap.Effective + fp := cap.Permitted + + cs.SetFlag(fe, true, vna) + + err := checkCapabilities(cs, fp, vna) + if err == nil { + t.Errorf("expected failed: %v", err) + } +} + +func TestCheckIpFwd(t *testing.T) { + testZeroPath := "/tmp/lobby_test_zero" + testOnePath := "/tmp/lobby_test_one" + testNilPath := "/tmp/lobby_test_nil" + zero := []byte("0") + one := []byte("1") + os.WriteFile(testZeroPath, zero, 0440) + os.WriteFile(testOnePath, one, 0440) + + exp := map[ipFwd]bool{ + ipFwdUnknown: true, + ipFwdNone: false, + ipFwdAll: false, + ipFwdV4Only: false, + ipFwdV6Only: false, + } + + ipv4FPath = testNilPath + res, err := checkIpFwd() + if err == nil { + t.Errorf( + "checkIpFwd expected to error due to failure to read ipv4 ip forwarding settings path, but it didn't error", + ) + } + + if check, ok := exp[res]; !ok { + t.Errorf( + "checkIpFwd result not found in expected IP Forwarding system settings. Res: '%v'", + res, + ) + } else { + if !check { + t.Errorf("unexpected ipFwd iota const returned") + } + } + + ipv4FPath = testZeroPath + ipv6FPath = testNilPath + res, err = checkIpFwd() + if err == nil { + t.Errorf( + "checkIpFwd expected to error due to failure to read ipv4 ip forwarding settings path, but it didn't error", + ) + } + + if check, ok := exp[res]; !ok { + t.Errorf( + "checkIpFwd result not found in expected IP Forwarding system settings. Res: '%v'", + res, + ) + } else { + if !check { + t.Errorf("unexpected ipFwd iota const returned") + } + } + exp[ipFwdUnknown] = false + + ipv4FPath = testZeroPath + ipv6FPath = testZeroPath + exp[ipFwdNone] = true + res, err = checkIpFwd() + if err != nil { + t.Errorf("checkIpFwd errored unexpectedly: '%v'", err) + } + + if check, ok := exp[res]; !ok { + t.Errorf( + "checkIpFwd result not found in expected IP Forwarding system settings. Res: '%v'", + res, + ) + } else { + if !check { + t.Errorf("unexpected ipFwd iota const returned") + } + } + exp[ipFwdNone] = false + + ipv4FPath = testOnePath + ipv6FPath = testZeroPath + exp[ipFwdV4Only] = true + res, err = checkIpFwd() + if err != nil { + t.Errorf("checkIpFwd errored unexpectedly: '%v'", err) + } + + if check, ok := exp[res]; !ok { + t.Errorf( + "checkIpFwd result not found in expected IP Forwarding system settings. Res: '%v'", + res, + ) + } else { + if !check { + t.Errorf("unexpected ipFwd iota const returned") + } + } + exp[ipFwdV4Only] = false + + ipv4FPath = testZeroPath + ipv6FPath = testOnePath + exp[ipFwdV6Only] = true + res, err = checkIpFwd() + if err != nil { + t.Errorf("checkIpFwd errored unexpectedly: '%v'", err) + } + + if check, ok := exp[res]; !ok { + t.Errorf( + "checkIpFwd result not found in expected IP Forwarding system settings. Res: '%v'", + res, + ) + } else { + if !check { + t.Errorf("unexpected ipFwd iota const returned") + } + } + exp[ipFwdV6Only] = false + + ipv4FPath = testOnePath + ipv6FPath = testOnePath + exp[ipFwdAll] = true + res, err = checkIpFwd() + if err != nil { + t.Errorf("checkIpFwd errored unexpectedly: '%v'", err) + } + + if check, ok := exp[res]; !ok { + t.Errorf( + "checkIpFwd result not found in expected IP Forwarding system settings. Res: '%v'", + res, + ) + } else { + if !check { + t.Errorf("unexpected ipFwd iota const returned") + } + } + exp[ipFwdAll] = false + + os.Remove(testZeroPath) + os.Remove(testOnePath) +} + +func TestGetHostType(t *testing.T) { + testCases := []struct { + input string + err error + result hostType + }{ + {input: "1.1.1.1", err: nil, result: hostTypeIPv4}, + {input: "dead:beef::1", err: nil, result: hostTypeIPv6}, + {input: "example.com.", err: nil, result: hostTypeFqdn}, + {input: "example.com", err: nil, result: hostTypeFqdn}, + {input: "11..12.1.", err: errHostType, result: hostTypeUnknown}, + {input: "1111.1.1.1", err: errHostType, result: hostTypeUnknown}, + } + + for _, tc := range testCases { + t.Run(tc.input, func(t *testing.T) { + ht, err := getHostType(tc.input) + if !errors.Is(err, tc.err) { + t.Errorf("%s: expected %v, but got %v", tc.input, tc.err, err) + } + if ht != tc.result { + t.Errorf("%s: expected %v, but got %v", tc.input, tc.result, ht) + } + }) + } +} + +func TestIsFqdn(t *testing.T) { + testCases := []struct { + input string + result bool + }{ + {input: "example.com", result: true}, + {input: "example.com.", result: true}, + {input: "testing.example.com", result: true}, + {input: "1.1.1.1", result: false}, + {input: "testing..example.com", result: false}, + {input: "testing@example.com", result: false}, + {input: "testing,example.com", result: false}, + {input: ".com", result: false}, + } + + for _, tc := range testCases { + t.Run(tc.input, func(t *testing.T) { + r := isFqdn(tc.input) + if r != tc.result { + t.Errorf("%s: expected %v, but got %v", tc.input, tc.result, r) + } + }) + } +} + +func TestFindUniqueNetIp(t *testing.T) { + testCases := []struct { + input []net.IP + result []net.IP + }{ + { + input: []net.IP{ + net.ParseIP("192.168.0.1"), + net.ParseIP("192.168.0.2"), + net.ParseIP("192.168.0.1"), + }, + result: []net.IP{ + net.ParseIP("192.168.0.1"), + net.ParseIP("192.168.0.2"), + }, + }, + { + input: []net.IP{ + net.ParseIP("2001:db8::1"), + net.ParseIP("2001:db8::2"), + net.ParseIP("2001:db8::1"), + }, + result: []net.IP{ + net.ParseIP("2001:db8::1"), + net.ParseIP("2001:db8::2"), + }, + }, + // Add more test cases as needed + } + + for _, tc := range testCases { + t.Run("", func(t *testing.T) { + result := findUniqueNetIp(&tc.input) + equal := reflect.DeepEqual(result, tc.result) + if !equal { + t.Errorf("For input %v, expected %v, but got %v", tc.input, tc.result, result) + } + }) + } +} + +func TestFindDuplicateNetIp(t *testing.T) { + testCases := []struct { + input []net.IP + result []net.IP + }{ + { + input: []net.IP{ + net.ParseIP("192.168.0.1"), + net.ParseIP("192.168.0.2"), + net.ParseIP("192.168.0.1"), + }, + result: []net.IP{ + net.ParseIP("192.168.0.1"), + }, + }, + { + input: []net.IP{ + net.ParseIP("2001:db8::1"), + net.ParseIP("2001:db8::2"), + net.ParseIP("2001:db8::1"), + }, + result: []net.IP{ + net.ParseIP("2001:db8::1"), + }, + }, + // Add more test cases as needed + } + + for _, tc := range testCases { + t.Run("", func(t *testing.T) { + result := findDuplicateNetIp(&tc.input) + equal := reflect.DeepEqual(result, tc.result) + if !equal { + t.Errorf("For input %v, expected %v, but got %v", tc.input, tc.result, result) + } + }) + } +} diff --git a/config_types.go b/config_types.go new file mode 100644 index 0000000..b189f8e --- /dev/null +++ b/config_types.go @@ -0,0 +1,52 @@ +package main + +// used for config file parsing + +type ProbeConfig struct { + CheckInterval uint16 `yaml:"check_interval"` + Timeout uint8 `yaml:"timeout"` + Count uint8 `yaml:"success_count"` +} + +type HealthCheckConfig struct { + Protocol string `yaml:"protocol"` + Port uint16 `yaml:"port"` + StartAvailable bool `yaml:"start_available"` + Probe ProbeConfig `yaml:"probe"` +} + +type UpstreamDnsConfig struct { + Servers []string `yaml:"servers"` + Ttl uint32 `yaml:"ttl"` +} + +type UpstreamsConfig struct { + Name string `yaml:"name"` + Host string `yaml:"host"` + Port uint16 `yaml:"port"` + Dns UpstreamDnsConfig `yaml:"dns"` + HealthCheck HealthCheckConfig `yaml:"health_check"` +} + +type UpstreamGroupConfig struct { + Name string `yaml:"name"` + Distribution string `yaml:"distribution"` + Upstreams []UpstreamsConfig `yaml:"upstreams"` +} + +type TargetsConfig struct { + Name string `yaml:"name"` + Protocol string `yaml:"protocol"` + Ip string `yaml:"ip"` + Port uint16 `yaml:"port"` + UpstreamGroup UpstreamGroupConfig `yaml:"upstream_group"` +} + +type LbConfig struct { + Engine string `yaml:"engine"` + TargetsConfig []TargetsConfig `yaml:"targets"` +} + +type ConfigYaml struct { + LbConfig []LbConfig `yaml:"lb"` +} diff --git a/dns.go b/dns.go new file mode 100644 index 0000000..53b54b9 --- /dev/null +++ b/dns.go @@ -0,0 +1,100 @@ +package main + +import ( + "errors" + "net" + + "github.com/miekg/dns" +) + +// hardcoded settings +const ( + // default DNS client config file + defaultDnsClientConfigFile = "/etc/resolv.conf" +) + +var ( + defaultDnsResolver = miekgResolver +) + +// DNS errors +var ( + errDnsHostRecordFail = errors.New( + "Couldn't resolve host record. Check your DNS", + ) + errDnsHostFqdnCheckFail = errors.New( + "Host is not valid fqdn", + ) +) + +type resolver func(string, []string) (net.IP, uint32, error) + +// A wrapper method to a resolver func +// In case a resolver func is not provided, it will call the defaultDnsResolver +func resolveFqdn(f string, dnsa []string, r resolver) (net.IP, uint32, error) { + if r == nil { + return defaultDnsResolver(f, dnsa) + } else { + return r(f, dnsa) + } +} + +// This func receives a FQDN (canonical names with a trailing dot) and a slice of DNS addresses +// It performs a A record DNS query of the provided FQDN on the supplied DNS addresses +// It will iterate through the DNS Addresses in sequential order until it gets a response or the list ends +// The function returns the first IP address from the response, the DNS TTL and an error +// It returns a non-null error in case a FQDN hasn't been provided or if the query fails on all provided DNS +func miekgResolver(f string, dnsa []string) (net.IP, uint32, error) { + if !dns.IsFqdn(f) { + LogDVf("DNS: '%s' is not a valid FQDN", f) + return nil, 0, errDnsHostFqdnCheckFail + } + LogDVf("DNS: '%s' is a valid FQDN", f) + + if len(dnsa) == 0 { + LogDf("DNS: no DNS addresses have been provided. Reading '%s'", defaultDnsClientConfigFile) + + dnsCfg, _ := dns.ClientConfigFromFile(defaultDnsClientConfigFile) + dnsa = dnsCfg.Servers + + } + + // Create DNS Client + c := new(dns.Client) + // Create DNS Query message + m := new(dns.Msg) + m.SetQuestion(f, dns.TypeA) + + // Iterate through each provided DNS address. + // Until the end of the list, + // unless a successful response is found. + for _, ns := range dnsa { + // Send DNS query + in, _, err := c.Exchange(m, ns+":"+"53") + if err != nil { + LogDVf("DNS: query failed. DNS: '%s' FQDN: '%s'", ns, f) + continue + } + if len(in.Answer) == 0 { + LogDVf("DNS: couldn't resolve. DNS: '%s' FQDN: '%s'", ns, f) + continue + } + for _, ans := range in.Answer { + if a, ok := ans.(*dns.A); ok { + ttl := ans.Header().Ttl + LogDVf( + "DNS: resolved successfully. DNS: '%s', FQDN: '%s', A Record: '%s', TTL: '%d'", + ns, + f, + a.A.String(), + ttl, + ) + return a.A, ttl, nil + } + } + } + + LogDVf("DNS: none of the provided DNS's was able to resolve '%s'", f) + + return nil, 0, errDnsHostRecordFail +} diff --git a/dns_test.go b/dns_test.go new file mode 100644 index 0000000..96ac83b --- /dev/null +++ b/dns_test.go @@ -0,0 +1,53 @@ +package main + +import ( + "net" + "testing" +) + +func TestResolveFqdnWithDnsAddresses_Success(t *testing.T) { + fqdn := "example.com." + dnsa := []string{"8.8.8.8", "8.8.4.4"} + ip, ttl, err := resolveFqdn(fqdn, dnsa, nil) + if err != nil { + t.Errorf("Error resolving FQDN: %v", err) + } + t.Logf("Resolved IP: %s, TTL: %d", ip, ttl) +} + +func TestResolveFqdnWithDnsAddresses_Failure(t *testing.T) { + fqdn := "example.5368235687." + dnsa := []string{"222.222.222.222", "8.8.8.8", "8.8.4.4"} + ip, ttl, err := resolveFqdn(fqdn, dnsa, nil) + if err == nil { + t.Errorf("Succeded, but should have failed: %v", err) + } + t.Logf("Resolved IP: %s, TTL: %d", ip, ttl) +} + +func TestResolveFqdnWithoutDnsAddresses(t *testing.T) { + fqdn := "example.com." + ip, ttl, err := resolveFqdn(fqdn, nil, nil) + if err != nil { + t.Errorf("Error resolving FQDN: %v", err) + } + t.Logf("Resolved IP: %s, TTL: %d", ip, ttl) +} + +func TestResolveFqdWrongFqdn(t *testing.T) { + fqdn := ".com" + _, _, err := resolveFqdn(fqdn, nil, nil) + if err == nil { + t.Errorf("Expected error due to erroneous FQDN, but didn't get it") + } +} + +func TestResolveFqdnWithCustomResolver(t *testing.T) { + fqdn := "example.com." + dnsa := []string{"8.8.8.8", "8.8.4.4"} + mockResolver := func(f string, dnsa []string) (net.IP, uint32, error) { + return net.ParseIP("1.1.1.1"), 600, nil + } + ip, ttl, _ := resolveFqdn(fqdn, dnsa, mockResolver) + t.Logf("Resolved IP: %s, TTL: %d", ip, ttl) +} diff --git a/docs/content/assets/img/lobbyGopherLogo.png b/docs/content/assets/img/lobbyGopherLogo.png new file mode 100644 index 0000000000000000000000000000000000000000..76e515539c73a9ec7a20da8b6cf247c81a49b19a GIT binary patch literal 98449 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 z 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 z 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 Y16pnqswn58ApigX07*qoM6N<$g3qAQ(f|Me literal 0 HcmV?d00001 diff --git a/docs/examples/demo.conf b/docs/examples/demo.conf new file mode 100644 index 0000000..302f9f9 --- /dev/null +++ b/docs/examples/demo.conf @@ -0,0 +1,49 @@ +lb: + - engine: nftables + targets: + - name: lobby-demo # unique target name + # A target listening on TCP port 8082, using 3 upstreams to load balance traffic in round-robin mode + protocol: tcp # transport protocol. Only tcp supported for now + port: 8082 # unique target port for a given protocol + upstream_group: # upstream_group to be used for target + name: lobby-demo-ug1 # unique upstream_group name + distribution: round-robin # ug traffic distribution mode. Only round-robin supported for now + upstreams: + - name: lobby-test-server1 # unique upstream name + # An upstream hosted at lobby-test.ipbuff.com IP address and port 8081 + # The system DNS's are used to resolve the upstream host FQDN + # No active health-checking and therefore the upstream will be considered always as available to receive traffic + host: lobby-test.ipbuff.com # upstream host. IP or FQDN + port: 8081 # upstream port + - name: lobby-test-server2 # unique upstream name + # An upstream hosted at lobby-test.ipbuff.com IP address and port 8082 + # The 1.1.1.1, 8.8.8.8 and 2606:4700::1111 DNS's are used to resolve the upstream host FQDN. The DNS will be re-queried every 300 seconds + # Active health-checking is performed on TCP port 8082, every 30 seconds. 3 consecutive successful probes are required to consider the upstream as available. A probe will fail after 1 seconds timeout + # The upstream will be considered as available when the load balancer starts + host: lobby-test.ipbuff.com # upstream host. IP or FQDN + port: 8082 # upstream port + dns: # include in case you want to use specific DNS to resolve the fqdn host address. If host is IPv4 or IPv6 this setting will not have any effect. In case this mapping is not present the OS resolvers will be used + servers: # dns address list. Queries will be done sequentially in case of failure + - 1.1.1.1 # cloudflare IPv4 DNS + - 8.8.8.8 # google IPv4 DNS. Used if 1.1.1.1 DNS fails to resolve + - 2606:4700::1111 # cloudflare IPv6 DNS. Used if 1.1.1.1 and 8.8.8.8 DNS fail to resolve + ttl: 300 # custom ttl can be specified to overwrite the DNS response TTL + health_check: # don't include the health-check mapping or leave it empty to disable health-check. upstreams will be considered alwasy as active when health-checks are not enabled + protocol: tcp # health-heck protocol. Only tcp supported for now + port: 8082 # health-check port. It can be different from the upstream port + start_available: true # set 'true' if upstream should be considered as available at start. set 'false' otherwise + probe: + check_interval: 30 # seconds. Max value: 65536 + timeout: 1 # seconds. Max value: 256 + success_count: 3 # amount of successful health checks to become active + - name: lobby-test-server3 # unique upstream name + # An upstream hosted at lobby-test.ipbuff.com IP address and port 8083 + # The 1.1.1.1, 8.8.8.8 and 2606:4700::1111 DNS's are used to resolve the upstream host FQDN + # No active health-checking and therefore the upstream will be considered always as available to receive traffic + host: lobby-test.ipbuff.com # upstream host. IP or FQDN + port: 8083 # upstream port + dns: # include in case you want to use specific DNS to resolve the fqdn host address. If host is IPv4 or IPv6 this setting will not have any effect. In case this mapping is not present the OS resolvers will be used + servers: # dns address list. Queries will be done sequentially in case of failure. The DNS will be re-requeried according to the TTL received in the DNS response + - 1.1.1.1 # cloudflare IPv4 DNS + - 8.8.8.8 # google IPv4 DNS. Used if 1.1.1.1 DNS fails to resolve + - 2606:4700::1111 # cloudflare IPv6 DNS. Used if 1.1.1.1 and 8.8.8.8 DNS fail to resolve diff --git a/docs/examples/lobby.conf b/docs/examples/lobby.conf new file mode 100644 index 0000000..46a11d6 --- /dev/null +++ b/docs/examples/lobby.conf @@ -0,0 +1,91 @@ +lb: + - engine: nftables + targets: + - name: target1 # unique target name + # A target listening on TCP port 8081, using 3 upstreams to load balance traffic in round-robin mode + protocol: tcp # transport protocol. Only tcp supported for now + port: 8081 # unique target port for a given protocol + upstream_group: + name: t1ug1 # unique upstream_group name + distribution: round-robin # ug traffic distribution mode. Only round-robin supported for now + upstreams: + - name: t1upstream1 # unique upstream name + # An upstream hosted at 1.1.1.1 IP address and port 80 + # No active health-checking and therefore the upstream will be considered always as available to receive traffic + host: 1.1.1.1 # upstream host. IP or FQDN + port: 80 # upstream port + - name: t1upstream2 # unique upstream name + # An upstream hosted at 1.1.1.2 IP address and port 80 + # Active health-checking is performed on TCP port 80, every 10 seconds. 3 consecutive successful probes are required to consider the upstream as available. A probe will fail after 2 seconds timeout + # The upstream will be considered as available when the load balancer starts + host: 1.1.1.2 # upstream host. IP or FQDN + port: 80 # upstream port + health_check: + protocol: tcp # health-heck protocol. Only tcp supported for now + port: 80 # health-check port. It can be different from the upstream port + start_available: true # set 'true' if upstream should be considered as available at start. set 'false' otherwise + probe: + check_interval: 10 # seconds. Max value: 65536 + timeout: 2 # seconds. Max value: 256 + success_count: 3 # amount of successful health checks for upstream to become available + - name: t1upstream3 # unique upstream name + # An upstream hosted at 1.1.1.3 IP address and port 80 + # Active health-checking is performed on TCP port 443, every 10 seconds. 5 consecutive successful probes are required to consider the upstream as available. A probe will fail after 1 seconds timeout + # The upstream will be considered as unavailable when the load balancer starts + protocol: tcp # transport protocol. Only tcp supported for now + host: 1.1.1.3 # upstream host. IP or FQDN + port: 80 # upstream port + health_check: + protocol: tcp # health-heck protocol. Only tcp supported for now + port: 443 # health-check port. It can be different from the upstream port + start_available: false # set 'true' if upstream should be considered as available at start. set 'false' otherwise + probe: + check_interval: 10 # seconds. Max value: 65536 + timeout: 1 # seconds. Max value: 256 + success_count: 5 # amount of successful health checks to become active + - name: target2 # unique target name + # A target listening on TCP port 8082, using 3 upstreams to load balance traffic in round-robin mode + protocol: tcp # transport protocol. Only tcp supported for now + port: 8082 # unique target port for a given protocol + upstream_group: # upstream_group to be used for target + name: t2ug1 # unique upstream_group name + distribution: round-robin # ug traffic distribution mode. Only round-robin supported for now + upstreams: + - name: lobby-test-server1 # unique upstream name + # An upstream hosted at lobby-test.ipbuff.com IP address and port 8081 + # The system DNS's are used to resolve the upstream host FQDN + # No active health-checking and therefore the upstream will be considered always as available to receive traffic + host: lobby-test.ipbuff.com # upstream host. IP or FQDN + port: 8081 # upstream port + - name: lobby-test-server2 # unique upstream name + # An upstream hosted at lobby-test.ipbuff.com IP address and port 8082 + # The 1.1.1.1, 8.8.8.8 and 2606:4700::1111 DNS's are used to resolve the upstream host FQDN. The DNS will be re-queried every 300 seconds + # Active health-checking is performed on TCP port 8082, every 30 seconds. 3 consecutive successful probes are required to consider the upstream as available. A probe will fail after 1 seconds timeout + # The upstream will be considered as available when the load balancer starts + host: lobby-test.ipbuff.com # upstream host. IP or FQDN + port: 8082 # upstream port + dns: # include in case you want to use specific DNS to resolve the fqdn host address. If host is IPv4 or IPv6 this setting will not have any effect. In case this mapping is not present the OS resolvers will be used + servers: # dns address list. Queries will be done sequentially in case of failure + - 1.1.1.1 # cloudflare IPv4 DNS + - 8.8.8.8 # google IPv4 DNS. Used if 1.1.1.1 DNS fails to resolve + - 2606:4700::1111 # cloudflare IPv6 DNS. Used if 1.1.1.1 and 8.8.8.8 DNS fail to resolve + ttl: 300 # custom ttl can be specified to overwrite the DNS response TTL + health_check: # don't include the health-check mapping or leave it empty to disable health-check. upstreams will be considered alwasy as active when health-checks are not enabled + protocol: tcp # health-heck protocol. Only tcp supported for now + port: 8082 # health-check port. It can be different from the upstream port + start_available: true # set 'true' if upstream should be considered as available at start. set 'false' otherwise + probe: + check_interval: 30 # seconds. Max value: 65536 + timeout: 1 # seconds. Max value: 256 + success_count: 3 # amount of successful health checks to become active + - name: lobby-test-server3 # unique upstream name + # An upstream hosted at lobby-test.ipbuff.com IP address and port 8083 + # The 1.1.1.1, 8.8.8.8 and 2606:4700::1111 DNS's are used to resolve the upstream host FQDN + # No active health-checking and therefore the upstream will be considered always as available to receive traffic + host: lobby-test.ipbuff.com # upstream host. IP or FQDN + port: 8083 # upstream port + dns: # include in case you want to use specific DNS to resolve the fqdn host address. If host is IPv4 or IPv6 this setting will not have any effect. In case this mapping is not present the OS resolvers will be used + servers: # dns address list. Queries will be done sequentially in case of failure. The DNS will be re-requeried according to the TTL received in the DNS response + - 1.1.1.1 # cloudflare IPv4 DNS + - 8.8.8.8 # google IPv4 DNS. Used if 1.1.1.1 DNS fails to resolve + - 2606:4700::1111 # cloudflare IPv6 DNS. Used if 1.1.1.1 and 8.8.8.8 DNS fail to resolve diff --git a/docs/examples/lobby.service b/docs/examples/lobby.service new file mode 100644 index 0000000..c4d162e --- /dev/null +++ b/docs/examples/lobby.service @@ -0,0 +1,21 @@ +[Unit] +Description=Lobby Load Balancer +After=network.target + +[Service] +Type=simple +User={{ username }} +ExecStart=/usr/local/bin/lobby +ExecStop=/bin/kill -s SIGINT $MAINPID +ExecReload=/bin/kill -s SIGHUP $MAINPID +TimeoutStartSec=0 +RestartSec=2 +Restart=always +StartLimitBurst=3 +StartLimitInterval=60s + +[Install] +WantedBy=multi-user.target + +[Install] +WantedBy=multi-user.target diff --git a/docs/src/docs/CNAME b/docs/src/docs/CNAME new file mode 100644 index 0000000..54b9cae --- /dev/null +++ b/docs/src/docs/CNAME @@ -0,0 +1 @@ +lobby.ipbuff.com diff --git a/docs/src/docs/about.md b/docs/src/docs/about.md new file mode 100644 index 0000000..9a6a1e2 --- /dev/null +++ b/docs/src/docs/about.md @@ -0,0 +1,11 @@ +## Credits +This project has been created by [Igor Borisoglebski](https://igor.borisoglebski.com). + +## Who's ipbuff +There is no entity behind ipbuff or ipbuff.com. This is a domain used by the author for its projects such as is the case for Lobby. + +## License +All contents of this page and Lobby project are licensed under [CC BY-NC-SA 4.0](http://creativecommons.org/licenses/by-nc-sa/4.0/). + +Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License + diff --git a/docs/src/docs/assets/lobbyConcepts.png b/docs/src/docs/assets/lobbyConcepts.png new file mode 100644 index 0000000000000000000000000000000000000000..d4d7f9fb5aaccd6daae8ba2c7489a5c1f7d47815 GIT binary patch literal 46895 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

yi9(?i|9XP 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 z%0D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 B9`*nL literal 0 HcmV?d00001 diff --git a/docs/src/docs/configuration.md b/docs/src/docs/configuration.md new file mode 100644 index 0000000..4720a2c --- /dev/null +++ b/docs/src/docs/configuration.md @@ -0,0 +1,180 @@ +A sample configuration file with all the configuration possibilies can be found below. + +Refer to [features](features.md) for additional details on each configuration parameter. + +``` yaml title="Example config file with comments" +lb: + - engine: nftables + targets: + - name: target1 # unique target name + # A target listening on TCP port 8081, using 3 upstreams to load balance traffic in round-robin mode + protocol: tcp # transport protocol. Only tcp supported for now + port: 8081 # unique target port for a given protocol + upstream_group: + name: t1ug1 # unique upstream_group name + distribution: round-robin # ug traffic distribution mode. Only round-robin supported for now + upstreams: + - name: t1upstream1 # unique upstream name + # An upstream hosted at 1.1.1.1 IP address and port 80 + # No active health-checking and therefore the upstream will be considered always as available to receive traffic + host: 1.1.1.1 # upstream host. IP or FQDN + port: 80 # upstream port + - name: t1upstream2 # unique upstream name + # An upstream hosted at 1.1.1.2 IP address and port 80 + # Active health-checking is performed on TCP port 80, every 10 seconds. 3 consecutive successful probes are required to consider the upstream as available. A probe will fail after 2 seconds timeout + # The upstream will be considered as available when the load balancer starts + host: 1.1.1.2 # upstream host. IP or FQDN + port: 80 # upstream port + health_check: + protocol: tcp # health-heck protocol. Only tcp supported for now + port: 80 # health-check port. It can be different from the upstream port + start_available: true # set 'true' if upstream should be considered as available at start. set 'false' otherwise + probe: + check_interval: 10 # seconds. Max value: 65536 + timeout: 2 # seconds. Max value: 256 + success_count: 3 # amount of successful health checks for upstream to become available + - name: t1upstream3 # unique upstream name + # An upstream hosted at 1.1.1.3 IP address and port 80 + # Active health-checking is performed on TCP port 443, every 10 seconds. 5 consecutive successful probes are required to consider the upstream as available. A probe will fail after 1 seconds timeout + # The upstream will be considered as unavailable when the load balancer starts + protocol: tcp # transport protocol. Only tcp supported for now + host: 1.1.1.3 # upstream host. IP or FQDN + port: 80 # upstream port + health_check: + protocol: tcp # health-heck protocol. Only tcp supported for now + port: 443 # health-check port. It can be different from the upstream port + start_available: false # set 'true' if upstream should be considered as available at start. set 'false' otherwise + probe: + check_interval: 10 # seconds. Max value: 65536 + timeout: 1 # seconds. Max value: 256 + success_count: 5 # amount of successful health checks to become active + - name: target2 # unique target name + # A target listening on TCP port 8082, using 3 upstreams to load balance traffic in round-robin mode + protocol: tcp # transport protocol. Only tcp supported for now + port: 8082 # unique target port for a given protocol + upstream_group: # upstream_group to be used for target + name: t2ug1 # unique upstream_group name + distribution: round-robin # ug traffic distribution mode. Only round-robin supported for now + upstreams: + - name: lobby-test-server1 # unique upstream name + # An upstream hosted at lobby-test.ipbuff.com IP address and port 8081 + # The system DNS's are used to resolve the upstream host FQDN + # No active health-checking and therefore the upstream will be considered always as available to receive traffic + protocol: tcp # transport protocol. Only tcp supported for now + host: lobby-test.ipbuff.com # upstream host. IP or FQDN + port: 8081 # upstream port + - name: lobby-test-server2 # unique upstream name + # An upstream hosted at lobby-test.ipbuff.com IP address and port 8082 + # The 1.1.1.1, 8.8.8.8 and 2606:4700::1111 DNS's are used to resolve the upstream host FQDN. The DNS will be re-queried every 300 seconds + # Active health-checking is performed on TCP port 8082, every 30 seconds. 3 consecutive successful probes are required to consider the upstream as available. A probe will fail after 1 seconds timeout + # The upstream will be considered as available when the load balancer starts + protocol: tcp # transport protocol. Only tcp supported for now + host: lobby-test.ipbuff.com # upstream host. IP or FQDN + port: 8082 # upstream port + dns: # include in case you want to use specific DNS to resolve the fqdn host address. If host is IPv4 or IPv6 this setting will not have any effect. In case this mapping is not present the OS resolvers will be used + servers: # dns address list. Queries will be done sequentially in case of failure + - 1.1.1.1 # cloudflare IPv4 DNS + - 8.8.8.8 # google IPv4 DNS. Used if 1.1.1.1 DNS fails to resolve + - 2606:4700::1111 # cloudflare IPv6 DNS. Used if 1.1.1.1 and 8.8.8.8 DNS fail to resolve + ttl: 300 # custom ttl can be specified to overwrite the DNS response TTL + health_check: # don't include the health-check mapping or leave it empty to disable health-check. upstreams will be considered alwasy as active when health-checks are not enabled + protocol: tcp # health-heck protocol. Only tcp supported for now + port: 8082 # health-check port. It can be different from the upstream port + start_available: true # set 'true' if upstream should be considered as available at start. set 'false' otherwise + probe: + check_interval: 30 # seconds. Max value: 65536 + timeout: 1 # seconds. Max value: 256 + success_count: 3 # amount of successful health checks to become active + - name: lobby-test-server3 # unique upstream name + # An upstream hosted at lobby-test.ipbuff.com IP address and port 8083 + # The 1.1.1.1, 8.8.8.8 and 2606:4700::1111 DNS's are used to resolve the upstream host FQDN + # No active health-checking and therefore the upstream will be considered always as available to receive traffic + protocol: tcp # transport protocol. Only tcp supported for now + host: lobby-test.ipbuff.com # upstream host. IP or FQDN + port: 8083 # upstream port + dns: # include in case you want to use specific DNS to resolve the fqdn host address. If host is IPv4 or IPv6 this setting will not have any effect. In case this mapping is not present the OS resolvers will be used + servers: # dns address list. Queries will be done sequentially in case of failure. The DNS will be re-requeried according to the TTL received in the DNS response + - 1.1.1.1 # cloudflare IPv4 DNS + - 8.8.8.8 # google IPv4 DNS. Used if 1.1.1.1 DNS fails to resolve + - 2606:4700::1111 # cloudflare IPv6 DNS. Used if 1.1.1.1 and 8.8.8.8 DNS fail to resolve +``` + +``` yaml title="Example config file without comments" +lb: + - engine: nftables + targets: + - name: target1 + protocol: tcp + port: 8081 + upstream_group: + name: t1ug1 + distribution: round-robin + upstreams: + - name: t1upstream1 + host: 1.1.1.1 + port: 80 + - name: t1upstream2 + host: 1.1.1.2 + port: 80 + health_check: + protocol: tcp + port: 80 + start_available: true + probe: + check_interval: 10 + timeout: 2 + success_count: 3 + - name: t1upstream3 + protocol: tcp + host: 1.1.1.3 + port: 80 + health_check: + protocol: tcp + port: 443 + start_available: false + probe: + check_interval: 10 + timeout: 1 + success_count: 5 + - name: target2 + protocol: tcp + port: 8082 + upstream_group: + name: t2ug1 + distribution: round-robin + upstreams: + - name: lobby-test-server1 + protocol: tcp + host: lobby-test.ipbuff.com + port: 8081 + - name: lobby-test-server2 + protocol: tcp + host: lobby-test.ipbuff.com + port: 8082 + dns: + servers: + - 1.1.1.1 + - 8.8.8.8 + - 2606:4700::1111 + ttl: 300 + health_check: + protocol: tcp + port: 8082 + start_available: true + probe: + check_interval: 30 + timeout: 1 + success_count: 3 + - name: lobby-test-server3 + protocol: tcp + host: lobby-test.ipbuff.com + port: 8083 + dns: + servers: + - 1.1.1.1 + - 8.8.8.8 + - 2606:4700::1111 +``` + +!!! note + As Lobby's config file is based on YAML, it will fail to load with a malformatted YAML config file. diff --git a/docs/src/docs/contacts.md b/docs/src/docs/contacts.md new file mode 100644 index 0000000..69d68f4 --- /dev/null +++ b/docs/src/docs/contacts.md @@ -0,0 +1,3 @@ +Lobby has been created by [Igor Borisoglebski](https://igor.borisoglebski.com). + +Feel free to [reach out](https://igor.borisoglebski.com/contacts)! diff --git a/docs/src/docs/features.md b/docs/src/docs/features.md new file mode 100644 index 0000000..1c1f1a0 --- /dev/null +++ b/docs/src/docs/features.md @@ -0,0 +1,187 @@ +Lobby leverages the Linux kernels networking stack for network traffic processing and therefore the load balancing is not performed at the application layer, but at the kernel level. + +To set up the kernel networking stack, Lobby uses the [netfilter](https://wikipedia.org/wiki/Netfilter) framework through the [nftables](https://wikipedia.org/wiki/Nftables) Linux kernel subsystem. + +

+![Lobby System Diagram](assets/lobbySystemDiagram.gif){ loading=lazy } +
Network packet processing with Lobby
+
+ +All of the configuration interpretation, upstream health checking, DNS resolution and respective traffic routing orchestration are peformed by Lobby. This approach is perfectly sufficient to implement comprehensive and efficient load balancing capabilities. + +## Concepts and Definitions + +These are some of the key concepts and definitions in Lobby's context. + +
+![Lobby Concepts](assets/lobbyConcepts.png){ loading=lazy } +
Lobby conceptual representation
+
+ +### Targets +A target is where the traffic is being expected at the Lobby host. Currently a target is only defined by the network protocol, such as TCP, and a network port. Each target must be unique. + +Targets use [upstream groups](#upstream-groups) to load balance traffic. + +| Definition | Description | +| - | - | +| **name** | unique name representing the target | +| **protocol** | the network protocol [`tcp`] | +| **port** | unique port for the specified protocol | +| **upstream_group** | the [upstream group](#upstream-groups) object linked to the target | + +### Upstream Groups +An upstream group is a collection of one or more [upstreams](#upstreams) associated to one [target](#targets). The definition of the distribution mode of the traffic across upstreams is done by an upstream group. + +| Definition | Description | +| - | - | +| **name** | unique name representing the upstream group | +| **distribution** | traffic distribution mode [[`round-robin`](#round-robin)] | +| **upstreams** | list of [upstream](#upstreams) objects linked to the upstream group | + +#### Distribution Modes +##### round-robin +All outgoing traffic is spread evenly across all of the available upstreams. + +### Upstreams +An upstream is a destination to which the traffic will be proxied to. Upstreams are defined by a network address (`host`) and a network port (`port`). + +| Definition | Description | +| - | - | +| **name** | unique name representing the upstream | +| **host** | upstream network address as IPv4 or FQDN | +| **port** | upstream network port | +| **health_check** | [health check](#health-check) object linked to the upstream | +| **dns** | [dns](#dns) object linked to the upstream | + +The `health_check` and `dns` definitions for the upstream are optional. + +In case no `health_check` object is linked to the upstream, the upstream will always be considered to be available to receive traffic. + +In case no `dns` object is linked to the upstream and the host is a FQDN, then the system DNS will be used to resolve the upstream address. + +#### Health Check +The [upstreams](#upstreams) may be configured to be subject to active health checks in order to monitor their readiness to receive traffic. Lobby will remove upstreams from the [upstream group](#upstream-groups) while they're unavailable and will be ensuring that the available upstreams are part of the respective upstream group. + +| Definition | Description | +| - | - | +| **protocol** | network protocol to be used for probing | +| **port** | network port to be used for probing | +| **start_available** | if the upstream should be available or unavailable at start [`true`, `false` ] | +| **probe** | [probe settings](#health-check-probe-settings) object linked to the health check | + +The health checks will always be performed against the upstream host address. However, it is possible to specify a different port and protocol. + +##### Health Check Probe Settings + +| Definition | Description | +| - | - | +| **check_interval** | frequency in seconds for the health check to occur | +| **timeout** | seconds to wait for response before considered as failed check | +| **success_count** | amount of successful checks before upstream is set as available | + +#### DNS +Lobby allows for upstream hosts to be configured as FQDN's. In order to resolve the FQDN's, a DNS object may be defined to specify which servers should be used to resolve the FQDN. + +It is also possible to overwrite the DNS Time-to-Live (TTL) field. + +| Definition | Description | +| - | - | +| **servers** | list of DNS addresses | +| **ttl** | frequency in seconds for the name resolution to occur | + +The FQDN name resolution will be performed by the first DNS address on the `servers` list. If it fails, the next DNS address in the list will be attempted and so on, until the name has been successfuly resolved or the DNS addresses exhasuted. + +In case the `servers` are not defined, the Lobby system host DNS addresses will be used. + +In case the `ttl` is not defined, the TTL received on the name resolution will be used. + +In a scenario where a FQDN has been previously resolved to an IP address, but that later the DNS stops resolving the FQDN, then Lobby will keep the last known IP address instead of making the upstream unavailable. + +### Config File Representation +A [YAML](https://yaml.org/) file is used to set the Lobby configuration in accordance to the features discription above. The format can be consulted in the [configuration](configuration.md) or [tutorials](tutorials.md) pages. + +## Feature Set + +A summary of the Lobby feature set may be found below. + +Feel free to [reach out]() in case you wish any of the feature development to be prioritized. + +### Internet Protocols +| Protocol | Implemented | +| ----------- | ----------------------- | +| IPv4 | :material-check: v0.1.0 | +| IPv6 | :material-close: | + +### Traffic Protocols +| Protocol | Implemented | +| ----------- | ----------------------- | +| TCP | :material-check: v0.1.0 | +| UDP | :material-close: | +| SCTP | :material-close: | +| HTTP | :material-close: | + +### Load Balancing Modes +| Mode | Implemented | +| ----------- | ----------------------- | +| round-robin | :material-check: v0.1.0 | +| random | :material-close: | +| weighted | :material-close: | +| ip-src-hash-based | :material-close: | +| least-latency | :material-close: | +| least-connections | :material-close: | + +### Upstream Health Check +| Feature | Implemented | +| ----------- | ----------------------- | +| TCP | :material-check: v0.1.0 | +| UDP | :material-close: | +| HTTP | :material-close: | +| Start available | :material-check: v0.1.0 | +| Start unavailable | :material-check: v0.1.0 | +| Probe timeout | :material-check: v0.1.0 | +| Probe check interval | :material-check: v0.1.0 | +| Probe healthy threshold | :material-check: v0.1.0 | +| Probe unhealthy threshold | :material-close: | + +### Upstream Name Resolution +| Feature | Implemented | +| ----------- | ----------------------- | +| Upstream FQDN address | :material-check: v0.1.0 | +| DNS backup | :material-check: v0.1.0 | +| DNS Load Balancing | :material-close: | +| Host System DNS | :material-check: v0.1.0 | +| Overwrite DNS TTL | :material-check: v0.1.0 | + +### Non-functional +| Feature | Implemented | +| ----------- | ----------------------- | +| YAML file based config | :material-check: v0.1.0 | +| Hot config reload | :material-check: v0.1.0 | +| API based config | :material-close: | +| Event triggers (ie alert) | :material-close: | +| Traffic mirroring | :material-close: | +| Metrics exposure | :material-close: | +| Prometheus endpoints | :material-close: | +| Graphical User Interface | :material-close: | +| systemd service | :material-close: | + +### Other + +Other features and ideas for possible future implementation: + +| Feature | Description | +| ----------- | ----------------------- | +| Target IP Range | Load balance on received IP range | +| Target Interface | Load balance on received interface | +| Packet Acceleration | Software and Hardware packet routing acceleration | +| IPv4 to IPv6 | Proxy from IPv4 targets to IPv6 upstreams | +| IPv6 to IPv4 | Proxy from IPv6 targets to IPv4 upstreams | +| Rate limits | Define traffic rating limits per target or upstream | +| Source IP allowed list | Only allow traffic from allowed listed IP addresses | +| Source IP block list | Drop traffic from block listed IP addresses | +| Lobby clustering | Lobby cluster coordination | +| Kubernetes agent | Configure Lobby based on Kubernetes services | +| Define Source address | Allow Lobby IP address to upstreams to be configured | +| Local traffic Load Balancing | Load balance locally generated traffic | +| Configurable nftables priority | Load balance locally generated traffic | diff --git a/docs/src/docs/index.md b/docs/src/docs/index.md new file mode 100644 index 0000000..191ee4b --- /dev/null +++ b/docs/src/docs/index.md @@ -0,0 +1,133 @@ +--- +title: Overview +--- + +**Lobby** is a simple, yet highly performant, network traffic load balancer based on Linux [nftables](https://wiki.nftables.org/wiki-nftables/). It is designed to run as a portable binary app on Linux systems in amd64 and arm64 architectures. + +## Getting Started (without docker) + +!!! info + In case you have :simple-docker: docker installed in the same host as where you're planning to run Lobby, follow [this](#getting-started-with-docker) instead. + +On a amd64 or arm64 Linux system, simply: + +``` bash title="Get Lobby" +wget -q -O - https://ipbuff.com/getLobby | sh +``` + +!!! success "" + + And that's it! It is all it takes to get Lobby available on your system + +This command downloads and runs a script which makes Lobby available on your current directory by downloading the Lobby binary to your current folder which becomes accessible with `./lobby` + +It also sets up a demo configuration file in `./lobby.conf`. The demo configuration will get Lobby to load balance all TCP traffic hitting the Lobby host on port `8082` to a cloud server used for Lobby testing at `lobby-test.ipbuff.com:8081`, `lobby-test.ipbuff.com:8082` and `lobby-test.ipbuff.com:8083` in [`round-robin`](features.md#round-robin) distribution mode. + +The only thing you have to ensure before running Lobby is that IP forwarding is enabled on your host system. In case you need help with this, check [this](https://www.baeldung.com/linux/kernel-ip-forwarding) article about it. + +You should be able to get Lobby up and running as a **privileged user** with: + +``` bash title="Run Lobby" +./lobby +``` + +!!! warning "In case of failure" + + If you get a permissions error, make sure you run `lobby` as 'root' user or with `sudo` or `doas`. To learn more on how to run as unprivileged user check [here](installation.md#permissions) + +If you haven't changed anything on the demo config file, you should be able to successfully target your Lobby host on port `8082` **from another machine** to test if Lobby is working correctly. + +This can be achieved in many ways. One of them is with: + +``` bash title="Test Lobby from another machine" +for i in {1..6}; do curl :8082; done +``` + +!!! success "Result" + + In case of success, this test will call the load balancer 6 times on port `8082` and Lobby will load balance the traffic in round-robin mode across 3 different Lobby test upstreams. + + The expected output is: + + ``` + 8081 + 8082 + 8083 + 8081 + 8082 + 8083 + ``` + +!!! warning "In case of failure" + + Make sure this test is performed from another machine as the traffic from the test machine is to be proxied through the Lobby host to the configured upstream. `curl localhost:8082` won't work and is expected to fail. + + If your only option is to test locally, consider using this [docker setup](installation.md#docker-network). + +To stop Lobby just send it an `SIGINT` signal with a `Ctrl + c` from the terminal that is running it or through the `kill` or `pkill` commands. + +In order to setup the load balancing as per your needs, feel free to edit the `./lobby.conf` config file. You'll be able to find the full configuration reference [here](configuration.md). + +With Lobby running, when it receives a `SIGHUP` signal, it will reprocess the config file and reconfigure the load balancing based on the updated config file contents. + +## Getting Started (with docker) + +!!! note + Before using the examples below, please make sure to run the commands from a directory in which you want to store the Lobby configuration file `lobby.conf` + +[This demo configuration](https://raw.githubusercontent.com/ipbuff/lobby/main/docs/examples/demo.conf) will be used for test purposes. + +``` bash title="Get Lobby Demo Config File" +wget -q \ + -O lobby.conf \ + https://raw.githubusercontent.com/ipbuff/lobby/main/docs/examples/demo.conf +``` + +In order to run Lobby on docker use the command below: + +``` bash +docker run --rm -d \ + --name lobby \ + --cap-add=NET_ADMIN --cap-add=NET_RAW \ + -v $(pwd)/lobby.conf:/lobby/lobby.conf \ + -p 8082:8082 \ + ipbuff/lobby:latest +``` + +Now, to test, let's check the load balancing to the Lobby test servers with: + +``` bash +for i in {1..6}; do curl localhost:8082; done +``` + +!!! success "Result" + + In case of success, this test will call the load balancer 6 times on port `8082` and Lobby will load balance the traffic in round-robin mode across 3 different Lobby test upstreams. + + The expected output is: + + ``` + 8081 + 8082 + 8083 + 8081 + 8082 + 8083 + ``` + +Finally, when you're done with testing, just stop the Lobby container with: + +``` bash +docker stop lobby +``` + +In order to setup the load balancing as per your needs, feel free to edit the `./lobby.conf` config file. You'll be able to find the full configuration reference [here](configuration.md). + +With Lobby running, when it receives a `SIGHUP` signal, it will reprocess the config file and reconfigure the load balancing based on the updated config file contents. To send a SIGHUP to the Lobby container just: + +``` bash +docker kill -s SIGHUP lobby +``` + +## Learning More +In case you're interested in learning more about Lobby, you'll be able to find here more about its [feature](features.md) set, [installation](installation.md) options, [configuration](configuration.md) reference, [tutorials](tutorials.md) and how to get additional [support](support.md). diff --git a/docs/src/docs/installation.md b/docs/src/docs/installation.md new file mode 100644 index 0000000..6b55e37 --- /dev/null +++ b/docs/src/docs/installation.md @@ -0,0 +1,144 @@ +## Requirements +### System +Up to the current version, Lobby was prepared to run on `Linux/amd64` and `Linux/arm64` systems. + +### Kernel +The Kernel must have the `nf_tables` module loaded or available to be loaded given that Lobby uses nftables to orchestrate the load balancing. + +IPv4 and IPv6 forwarding must also be enabled through the kernel parameters. In most Linux systems `sysctl` can be used to check and enable/disable IP forwarding. Check [here](https://www.baeldung.com/linux/kernel-ip-forwarding), for instance, in case you need help with regards to managing IP forwarding in your systems. + +### Permissions +The Lobby binary must have the `NET_ADMIN` and `NET_RAW` [linux capabilities](https://man7.org/linux/man-pages/man7/capabilities.7.html) set to `Permitted` and `Effective`. + +In most systems this can be achieved with the following command executed by the `root` user: + +``` bash +setcap 'cap_net_admin,cap_net_raw+ep' /path/to/lobby +``` + +Also ensure that the binary can (only?) be executed by the appropriate user and group with `chown` and `chmod`. + +## Binary +Pre-built binaries are available [here](https://github.com/ipbuff/lobby/releases/) and are named: + + - for linux/amd64 systems: `lobby-linux-amd64` + - for linux/arm64 systems: `lobby-linux-arm64` + +As an alternative to direct binaries, the [releases](https://github.com/ipbuff/lobby/releases/) also include scripts to download the binary and a demo configuration file. + +The binary can be located anywhere, but consider placing it named `lobby` in one of your `$PATH` directories such as `/usr/local/bin` or `/usr/bin`. + +Lobby has its load balancing rules set through a config file. Lobby will look for a config file named `lobby.conf` in its local directory and if not found in its local directory, it will then try to open it from `/etc/lobby/lobby.conf`. If you've placed Lobby in one of your `$PATH` directories, then place the configuration file in `/etc/lobby/lobby.conf`. It is also possible to specify the config file with the `-c` flag such as `lobby -c /path/to/config/file.yaml`. + +## Building from Source +The Lobby source code is publicly available at [:simple-github: Github](https://github.com/ipbuff/lobby). + +In order to build from source, make sure you have the [go environment set up](https://go.dev/doc/install) and then simply clone the repo and use `make build` from within the repo directory. + +There's a tutorial [here](tutorials.md#building-from-source). + +## Docker +Lobby containers are published on [:simple-docker: docker hub](https://hub.docker.com/r/ipbuff/lobby). + +The containers have been built with ["Distroless"](https://github.com/GoogleContainerTools/distroless) images. Therefore, it will not possible to run anything else other than Lobby on those containers. + +!!! note + Before using the examples below, please make sure to run the commands from a directory in which the Lobby configuration file `lobby.conf` exists and contains valid configuration. + + Otherwise, adjust the commands accordingly. + + [This demo configuration](https://raw.githubusercontent.com/ipbuff/lobby/main/docs/examples/demo.conf) could be used for test purposes. + +In order to run Lobby on docker while using a separate network namespace for the container and exposing locally some of the ports consider the command below: + +``` bash +docker run -d \ + --name lobby \ + --cap-add=NET_ADMIN --cap-add=NET_RAW \ + -v $(pwd)/lobby.conf:/lobby/lobby.conf \ + -p 8081:8081 -p 8082:8082 \ + ipbuff/lobby:latest +``` + +The `-d` flag runs the container in [dettached](https://docs.docker.com/language/golang/run-containers/#run-in-detached-mode) mode. + +The `name` flag sets the container name. + +The `--cap-add` provides the `NET_ADMIN` and `NET_RAW` linux capabilites to the container processes. + +The `-v` flag mounts the local `./lobby.conf` config file in the container on the `/lobby/lobby.conf` path. + +The `-p` flag is used to map a local port to a port within the container. + +`ipbuff/lobby:latest` uses the latest image for the `ipbuff/lobby` container hosted on docker hub. + +!!! note + + This method whilst it might be convenient, it is potentially a non-optimal setup for production environments as docker sets up a NAT rule to get the traffic from the local port to the container address and port. This adds extra computation when compared to other options. Additionally, it also complicates on how to expose additional ports as result of configuration changes. + +## systemd +Running Lobby as a systemd requires setting the lobby service file in `/etc/systemd/system/`. + +[Here](https://raw.githubusercontent.com/ipbuff/lobby/main/docs/examples/lobby.service)'s a sample systemd service file which can be used after replacing the `User`, `WorkingDirectory` and `ExecStart` parameters : + +``` systemd title="/etc/systemd/system/lobby.service" +[Unit] +Description=Lobby Load Balancer +After=network.target + +[Service] +Type=simple +User={{ username }} +ExecStart=/usr/local/bin/lobby +ExecStop=/bin/kill -s SIGINT $MAINPID +ExecReload=/bin/kill -s SIGHUP $MAINPID +TimeoutStartSec=0 +RestartSec=2 +Restart=always +StartLimitBurst=3 +StartLimitInterval=60s + +[Install] +WantedBy=multi-user.target +``` + +Make sure that if the user is not set to `root`, the lobby binary has the required [Linux capabilities](#permissions). + +Once the `/etc/systemd/system/lobby.service` file is created, consider setting it with the appropriate ownership and permissions. If unsure, use: + +``` bash +chown root:root /etc/systemd/system/lobby.service +chmod 644 /etc/systemd/system/lobby.service +``` + +And then finally reload the systemd config with: + +``` bash +systemctl daemon-reload +``` + +The lobby service then can be enabled with: + +``` bash +systemctl start lobby +``` + +And it should be enabled in case you wish it to start every time the system boots: + +``` bash +systemctl enable lobby +``` + +In order to stop Lobby: + +``` bash +systemctl stop lobby +``` + +It is possible to refresh the Lobby configuration after editing the config file with: + +``` bash +systemctl reload lobby +``` + +There's a [script](https://raw.githubusercontent.com/ipbuff/lobby/main/docs/examples/lobby.service) which helps with the Lobby systemd service setup. Its usage is documented in the [tutorials](tutorials.md#lobby-systemd-service). diff --git a/docs/src/docs/stylesheets/extra.css b/docs/src/docs/stylesheets/extra.css new file mode 100644 index 0000000..c408ddb --- /dev/null +++ b/docs/src/docs/stylesheets/extra.css @@ -0,0 +1,5 @@ +[data-md-color-scheme="default"] { + --md-default-bg-color: #f7f7f7; + --md-code-bg-color: #fff; +} + diff --git a/docs/src/docs/support.md b/docs/src/docs/support.md new file mode 100644 index 0000000..08391cd --- /dev/null +++ b/docs/src/docs/support.md @@ -0,0 +1,3 @@ +In case you're in need of support, the best way to get it is through the [:simple-github: github issues](https://github.com/ipbuff/lobby/issues) of the Lobby repo. + +Professional support can be provided by the project creator. In case this is of interest, feel free to [reach out](https://igor.borisoglebski.com). diff --git a/docs/src/docs/tutorials.md b/docs/src/docs/tutorials.md new file mode 100644 index 0000000..b54ac83 --- /dev/null +++ b/docs/src/docs/tutorials.md @@ -0,0 +1,553 @@ +## Building from source +In order to build Lobby from source, it is required to do so from an environment prepared to [build go projects](https://go.dev/doc/install). + +The build process is pretty simple. Go to a directory of your choosing and from there: + +``` +git clone --depth 1 https://github.com/ipbuff/lobby.git +cd lobby +make build +``` + +Following the successful build, the resulting binaries can be found in the `./bin` directory. + +There will be two files: + + - `lobby-linux-amd64` for Linux system and amd64 architecture + - `lobby-linux-arm64` for Linux system and arm64 architecture + +## Getting the binaries and running Lobby +The Lobby binaries can be found on the [releases](https://github.com/ipbuff/lobby/releases) of the Lobby :simple-github: Github repo. + +In case you have :simple-docker: docker installed on the same host as where you're planning to run Lobby, do not use the Lobby binaries and use the [docker](#docker) methods instead. The default :simple-docker: docker iptables/nftables rules drop all the forwarding traffic. In case you still want to proceed with the binary setup in a server which also has docker, then you'll have to edit the docker generated iptables/nftables rules. + +### Download Lobby +The simplest method is to use the [`getLobby.sh`](https://github.com/ipbuff/lobby/blob/main/scripts/getLobby.sh) script which will detect your system and download the appropriate binary for your system. This script will also set a demo Lobby config file which can be used to test Lobby. This script is available [here](https://ipbuff.com/getLobby). + +Start by creating a directory where you want to download the Lobby binaries to. For this example, `~/lobby` will be used. The directory can be created with: + +``` bash title="Create directory to store Lobby" +LOBBY_DIR=~/lobby +mkdir $LOBBY_DIR && \ +cd $LOBBY_DIR && \ +echo Successfully created the lobby dir || \ +echo Failed to create the lobby dir +``` + +A way to download the script and run it can be: + +``` title="Get Lobby Binary and Config File" +wget -q -O - https://ipbuff.com/getLobby | sh +``` + +Now if you check the contents of the directory, you should find there the Lobby binary named `lobby` and the demo configuration file named `lobby.conf`. + +``` bash title="List Directory Contents" +ls +``` + +### CLI Help +To get the command line interface (cli) help, which prints the flags available for Lobby, run Lobby with the `-h` flag. + +```bash title="Print cli help" +./lobby -h +``` + +### Check Version +To check the Lobby version just run lobby with the `-v` flag. The version will be printed as result and the app will quit. + +``` bash title="Check Lobby version" +./lobby -v +``` + +### Setting permissions +At this stage, you have to decide with which user you want to run Lobby with. Eventually the simplest way is to proceed as `root`, but we'll proceed this tutorial with the consideration that best practices should be followed even in introductory tutorials and in that case we'll assume that an unprivileged user is to be used. + +The script executed at [this](#download-lobby) step has already ensured that the binary can only be executed by the owner user and group. Additionally, in order to run Lobby with an unprivileged user it is necessary to give permissions to the binary to run with a couple of linux capabilities which allow it to manage specifically the nftables locally. The capabilites are `NET_ADMIN` and `NET_RAW` and we'll follow the instructions described [here](installation.md#permissions) to set them for the Lobby binary with the following command executed by the `root` user: + +``` bash title="Execute as root user" +setcap 'cap_net_admin,cap_net_raw+ep' lobby +``` + +The success of the command can be confirmed with: + +``` bash +getcap lobby +``` + +!!! success + + With the expected output being: + + ``` bash + lobby cap_net_admin,cap_net_raw=ep + ``` + +From here on, it is possible to run Lobby as an unprivileged user. + +Keep in mind that the Linux capabilities have to be set every time a new binary is built/downloaded - ie after every upgrade + +### Running Lobby +Running Lobby without any flags will start the load balancer with the local config file named `lobby.conf`. In case `lobby.conf` is not found or is inaccessible, then the `/etc/lobby/lobby.conf` path will be attempted next. In case either of the files fails to open, Lobby will quit with error code 1. + +!!! note + On [this](#download-lobby) step, [this](https://raw.githubusercontent.com/ipbuff/lobby/main/docs/examples/demo.conf) demo config file has been setup locally. In case you have followed a different method to set up the Lobby binaries, make sure that there is a Lobby configuration file at either the local directory of the Lobby binary or at `/etc/lobby/lobby.conf` + +Lobby can be started simply with: + +``` bash title="Start Lobby" +./lobby +``` + +#### Test Lobby +As Lobby doesn't load balances localhost traffic, the tests have to be performed from another machine targeting the Lobby host. + +If you haven't changed anything on the demo config file, you should be able to successfully target your Lobby host on port `8082` **from another machine** to test if Lobby is working correctly. + +This can be achieved in many ways. One of them is with: + +``` bash title="Test Lobby from another machine" +for i in {1..6}; do curl :8082; done +``` + +!!! success "Result" + + In case of success, this test will call the load balancer 6 times on port `8082` and Lobby will load balance the traffic in round-robin mode across 3 different Lobby test upstreams. + + The expected output is: + + ``` + 8081 + 8082 + 8083 + 8081 + 8082 + 8083 + ``` + +!!! warning "In case of failure" + + Make sure this test is performed from another machine as the traffic from the test machine is to be proxied through the Lobby host to the configured upstream. `curl localhost:8082` won't work and is expected to fail. + + If your only option is to test locally, consider using this [docker setup](#docker). + +### Update Config +Lobby supports hot reloading of the configuration, which means that it is possible to update the configuration without having to stop and start the load balancer with the benefit of no traffic being temporarily lost in the process. + +This is achieved by updating the configuration file in use by Lobby and sending a SIGHUP signal to the running process. + +As an example, while Lobby is running, let's add another upstream to `target2` by appending the following config, which will add a new upstream to `target2`. + +``` bash +echo " - name: lobby-test-server4" >> lobby.conf +echo " host: lobby-test.ipbuff.com" >> lobby.conf +echo " port: 8084" >> lobby.conf +``` + +Now that the config file has been updated, we just need to send a `SIGHUP` signal to the running process of Lobby. This can be achieved with: + +``` bash +pkill -SIGHUP lobby +``` + +Now that Lobby has reconfigured we can run the same test: + +``` bash title="Test Lobby from another machine" +for i in {1..6}; do curl :8082; done +``` + +!!! success "Result" + + And now given we have one extra test upstream, the expected output is: + + ``` + 8081 + 8082 + 8083 + 8084 + 8081 + 8082 + ``` + +### Stopping Lobby +Lobby can be stopped with `Ctrl+c` on the terminal where it is running in the foreground or with a SIGINT signal to the running process with: + +``` bash +pkill -SIGINT lobby +``` + +### Specifying a Config File +Lobby supports specifying a path to a specific config file. This can be achieved with the `-c` flag. For example: + +``` bash +./lobby -c +``` + +### Verbosity Level +The default logging verbosity for Lobby is `Info`. It is possible to start Lobby with different levels of logging verbosity. This is achieved with the `-l` flag. + +The possible logging levels are: + +| Log Level | Description | Flag string | Displays | +|---------------- | ------------------------- | -------------- | -------- | +| Critical | Fatal errors and similar | `critical` | `critical` | +| Warning | Potentially problematic events | `warning` | `critical`/`warning` | +| Info | Potentially relevant information | `info` | `critical`/`warning`/`info` | +| Debug | User debugging level | `debug` | `critical`/`warning`/`info`/`debug` | +| DebugVerbose | Developer debugging level | `debugverbose` | `critical`/`warning`/`info`/`debug`/`debugverbose` | + +So, for instance to set the logging level to Debug level, start Lobby with: + +``` bash +./lobby -l debug +``` + +## Lobby systemd Service +Other than for testing purposes, it is recommended to run Lobby as a systemd service. + +### Install Lobby (systemd) +[This](https://github.com/ipbuff/lobby/releases/latest/download/installLobby.sh) script provides a way to setup Lobby as a systemd service. It does the following: + + - Downloads the latest Lobby binary for the appropriate system + - Places the binary in `/usr/local/bin/lobby` with conservative permissions + - Sets a demo config file in `/etc/lobby/lobby.conf` + - Creates a systemd service until file for Lobby + - at `/etc/systemd/system/lobby.service` for `root` user + - otherwise at `~/.config/systemd/user/lobby.service` for other users + +The script accepts a username as an argument in order to install Lobby with the appopriate permissions for the given user. In case no argument is provided, it is assumed that the installation is to be done for the `root` user. + +The installation script has to be run as `root` user given it requires privileged permissions to set everything thing up. + +#### As root user systemd service +In order to use the script to install Lobby for the `root` user: + +``` bash title="Run as root user" +wget -q -O - https://ipbuff.com/installLobby | sh +``` + +Following the succesful completion of the installation script it is necessary to reload the systemd deamon in order to make the Lobby systemd service available. This can be achieved by running the following command as `root` user: + +``` +systemctl daemon-reload +``` + +Now you can start Lobby with: + +``` +systemctl start lobby +``` + +And in case you want Lobby to start automatically at system boot: + +``` +systemctl enable lobby +``` + +To stop the Lobby service: + +``` +systemctl stop lobby +``` + +And to refresh the Lobby configuration following config file changes: + +``` +systemctl reload lobby +``` + +##### Security Improvement +While leaving the systemd service managed by root, so it can be started automatically at every system boot, consider executing the Lobby binary with an unprivileged user. + +For that purpose, create a specific user, give `rwx` permissions to it or its group to the `/etc/lobby/` directory and at least `x` to the binary `/usr/local/bin/lobby`. + +Then to ensure that the systemd service runs Lobby with the desired user add the `User=changeMe` argument somewhere in the `[Service]` section of the Lobby systemd service unit file at `/etc/systemd/system/lobby.service`. From the previous example change `changeMe` to the user that was created and to be used. + +Once the Lobby systemd service unit file has been updated with the `User` argument under the `[Service]` section, refresh systemd with: + +``` +systemctl daemon-reload +``` + +And for good measure stop, disable and enable and start again the Lobby systemd service: + +``` +systemctl stop lobby +systemctl disable lobby +systemctl enable lobby +systemctl start lobby +``` + +#### As non-root user systemd service +In order to use the script to install Lobby for the `john` user: + +``` title="Run as root user and replace 'john' with your username" +wget -q -O - https://ipbuff.com/installLobby | sh -s john +``` + +Following the succesful completion of the installation script it is necessary to reload the systemd deamon in order to make the Lobby systemd service available. In case you've proceeded with the installation as a `non-root` user, this can be achieved by running the following command with the desired user: + +``` +systemctl --user daemon-reload +``` + +Now you can start Lobby with: + +``` +systemctl --user start lobby +``` + +And in case you want Lobby to start automatically at **first user login**: + +``` +systemctl --user enable lobby +``` + +To stop the Lobby service: + +``` +systemctl --user stop lobby +``` + +And to refresh the Lobby configuration following config file changes: + +``` +systemctl --user reload lobby +``` + +### Uninstall Lobby (systemd) +It is recommended to stop and disable the Lobby systemd service before uninstalling Lobby. + +In order to uninstall Lobby in case the [`installLobby.sh`](https://github.com/ipbuff/lobby/releases/latest/download/installLobby.sh) script was used, all that is necessary is to remove: + +- the binary located in `/usr/local/bin/lobby` +- the configuration directory at `/etc/lobby/` +- eventually the systemd service unit files for the root user at `/etc/systemd/system/lobby.service` +- eventually the systemd service unit files for non-root users + +A [script](https://github.com/ipbuff/lobby/releases/latest/download/uninstallLobby.sh) has been prepared for that. Before using it, backup your config file as it is removed as part of the uninstall process. + +You can run it as a root user with: + +!!! note + Do not forget to stop and disable the Lobby systemd service beforehand + +``` title="Run as root" +wget -q -O - https://ipbuff.com/uninstallLobby | sh +``` + +## Docker +This tutorial will be assuming your default docker network is a bridge network (as it is most likely the case and) as it happens by default after docker install. In case this is not the case, adjust the steps and commands accordingly. + +Change directory to where you will be storing your Lobby config file before proceeding with this docker tutorial. + +### Check Version +To start with, let's check the Lobby version from the container images hosted at [:simple-docker: docker hub](https://hub.docker.com/r/ipbuff/lobby). + +``` +docker run --rm \ + --name lobby \ + --cap-add=NET_ADMIN --cap-add=NET_RAW \ + ipbuff/lobby:latest \ + ./lobby -v +``` + +### Running Lobby on Docker +Running Lobby without any flags will start the load balancer with the local config file named `lobby.conf`. + +Generate your own Lobby config file or download a demo config file like so: + +``` bash +wget \ + -O lobby.conf \ + -q https://raw.githubusercontent.com/ipbuff/lobby/main/docs/examples/demo.conf +``` + +Then Lobby can be started simply with: + +``` +docker run --rm -d \ + --name lobby \ + --cap-add=NET_ADMIN --cap-add=NET_RAW \ + -v $(pwd)/lobby.conf:/lobby/lobby.conf \ + -p 8082:8082 \ + ipbuff/lobby:latest +``` + +#### Test Lobby on Docker +If you haven't changed anything on the demo config file, you should be able to successfully target your port `8082` to test if Lobby is working correctly. + +This can be achieved in many ways. One of them is with: + +``` bash title="Test Lobby from another machine" +for i in {1..6}; do curl localhost:8082; done +``` + +!!! success "Result" + + In case of success, this test will call the load balancer 6 times on port `8082` and Lobby will load balance the traffic in round-robin mode across 3 different Lobby test upstreams. + + The expected output is: + + ``` + 8081 + 8082 + 8083 + 8081 + 8082 + 8083 + ``` + +### Update Config on Docker +As in [here](#update-config), while Lobby is running, let's add another upstream to `target2` by appending the following config, which will add a new upstream to `target2`. + +``` bash +echo " - name: lobby-test-server4" >> lobby.conf +echo " host: lobby-test.ipbuff.com" >> lobby.conf +echo " port: 8084" >> lobby.conf +``` + +Now that the config file has been updated, we just need to send a `SIGHUP` signal to the running process of Lobby in the container. This can be achieved with: + +``` +docker kill -s SIGHUP lobby +``` + +Now that Lobby has reconfigured we can run the same test: + +``` bash title="Test Lobby from another machine" +for i in {1..6}; do curl ${LOBBY_IP}:8082; done +``` + +!!! success "Result" + + And now given we have one extra test upstream, the expected output is: + + ``` + 8081 + 8082 + 8083 + 8084 + 8081 + 8082 + ``` + +### Stopping Lobby on Docker +Lobby can be stopped with `Ctrl+c` on the terminal where it is running in the foreground or with a SIGINT signal to the running process with: + +``` +docker kill -s SIGINT lobby +``` + +### Verbosity level on Docker +As explained [here](#verbosity-level), for instance to set the logging level to Debug level, start Lobby with: + +``` +docker run --rm -d \ + --name lobby \ + --cap-add=NET_ADMIN --cap-add=NET_RAW \ + -v $(pwd)/lobby.conf:/lobby/lobby.conf \ + -p 8082:8082 + ipbuff/lobby:latest \ + ./lobby -l debug +``` + +### Things to Note +#### Container Ports +For [this](#running-lobby-on-docker) type of docker deployment, consider specifying a range of ports to be exposed for the Lobby docker container, instead of individual ones. This is in case future load balancing configuration requires load balancing on new/other ports. For instance, to expose from port 8000 to 9000 one could use the `-p 8000-9000:8000-9000` flag instead. + +#### Production on Docker +For production deployments, consider creating a specific docker network to be used by exclusively by the Lobby containers and not subject to NAT'ing. + +## Considerations for Production Deployments +The examples above are mostly useful for local testing or for home/small load applications. + +However, to host Lobby for production it is important to have in consideration the wider context of the deployment, such as: + +- The traffic routing to the Lobby server host +- The traffic routing from the Lobby server host +- Lobby traffic isolation +- The Lobby server host resources +- Lobby redundancy +- etc + +Nevertheless, for a high performance, high throughput and high bandwidth environment, consider the [systemd service](#lobby-systemd-service) or similar deployment type for Lobby in a host that is protected by a firewall and where the Lobby internet address is available at the Lobby host without NAT rules in between. The Lobby (or other load balancers) redundancy implementation depends on the use case and therefore cannot be subject to a generic recommendation in this tutorial. + +In case of need for support on production deployments or operation, consider reaching out to the [project creator](https://igor.borisoglebski.com) for consultancy and professional services. + +## Traffic Stats +This part of Lobby is still in its infancy and unfortunately in extremelly low capacity. Feel free to reach out to the [project creator](https://igor.borisoglebski.com) in case you're willing to support the acceleration of the development or feel free contribute with the capability through the [:simple-github:Github project](https://github.com/ipbuff/lobby). + +However, the most basic measure has been implemented and can be checked through `nftables`. All targets are assigned a [nftables counter](https://wiki.nftables.org/wiki-nftables/index.php/Counters) which keeps track of the traffic incoming to each target. This can be checked for instance with: + +``` bash +nft list counters +``` + +It outputs the packets and bytes incoming to each target. + + +This method will be further enhanced to keep track of more things, accessible through other methods and integratable with external systems such as prometheus. + +## Generating Config +Generating the Lobby config should be relatively intuitive for anyone who has worked with [YAML](https://yaml.org/) structures before. + +### One Target to Two Upstreams +Let's create a config file with Lobby listening TCP traffic on port 8082 and load balancing to two upstreams in round-robin distribution mode where one of the upstreams is expecting traffic on IP 1.1.1.1 and port 80, while the other one on IP 1.1.1.2 and also port 80. + +Every config file starts with: + +``` yaml +lb: + - engine: nftables + targets: +``` + +We start by adding the target definition which is where Lobby is listening for that type of traffic with: + +``` yaml +lb: + - engine: nftables + targets: + - name: toCloudflare + protocol: tcp + port: 8082 +``` + +Now that we have defined where Lobby is listening, we need to specify how the traffic is distributed: + +``` yaml +lb: + - engine: nftables + targets: + - name: toCloudflare + protocol: tcp + port: 8082 + upstream_group: + name: cloudflareServers + distribution: round-robin + upstreams: +``` + +At this stage, this config file is not valid yet, because we haven't specified the upstream servers to where the traffic will be load balanced. So, we need to add them: + +``` yaml +lb: + - engine: nftables + targets: + - name: toCloudflare + protocol: tcp + port: 8082 + upstream_group: + name: cloudflareServers + distribution: round-robin + upstreams: + - name: cloudflareServer1 + host: 1.1.1.1 + port: 80 + - name: cloudflareServer2 + host: 1.1.1.2 + port: 80 +``` + +And that's it! The most basic load balancing config file was created. For further references check [here](configuration.md). diff --git a/docs/src/mkdocs.yml b/docs/src/mkdocs.yml new file mode 100644 index 0000000..666a955 --- /dev/null +++ b/docs/src/mkdocs.yml @@ -0,0 +1,66 @@ +site_name: Lobby +copyright: Copyright © 2023 - Igor Borisoglebski +repo_url: https://github.com/ipbuff/lobby +theme: + name: material + language: en + favicon: assets/logo.png + logo: assets/logo.png + palette: + - media: "(prefers-color-scheme: light)" + scheme: default + primary: black + accent: blue + toggle: + icon: material/toggle-switch + name: Switch to dark mode + - media: "(prefers-color-scheme: dark)" + scheme: slate + primary: black + accent: blue + toggle: + icon: material/toggle-switch-off-outline + name: Switch to light mode + font: false + features: + - navigation.footer + - navigation.instant + - navigation.sections + - navigation.expand + - navigation.top + - toc.follow + - search.highlight + - content.code.copy +nav: + - index.md + - features.md + - installation.md + - configuration.md + - tutorials.md + - support.md + - contacts.md + - about.md +extra: + social: + - icon: fontawesome/brands/github + link: https://github.com/ipbuff/lobby/ + name: Lobby on Github +extra_css: + - stylesheets/extra.css +markdown_extensions: + - admonition + - pymdownx.details + - pymdownx.superfences + - pymdownx.highlight: + anchor_linenums: true + line_spans: __span + pygments_lang_class: true + - pymdownx.inlinehilite + - pymdownx.snippets + - attr_list + - md_in_html + - pymdownx.emoji: + emoji_index: !!python/name:material.extensions.emoji.twemoji + emoji_generator: !!python/name:material.extensions.emoji.to_svg +plugins: + - awesome-pages diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..54e38e9 --- /dev/null +++ b/go.mod @@ -0,0 +1,26 @@ +module git.borisoglebski.com/lobby + +go 1.20 + +require ( + github.com/google/nftables v0.1.0 + github.com/mdlayher/netlink v1.7.2 // indirect + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/miekg/dns v1.1.57 + golang.org/x/sys v0.15.0 + kernel.org/pub/linux/libs/security/libcap/cap v1.2.69 +) + +require ( + github.com/google/go-cmp v0.6.0 // indirect + github.com/josharian/native v1.1.0 // indirect + github.com/mdlayher/socket v0.5.0 // indirect + golang.org/x/mod v0.14.0 // indirect + golang.org/x/net v0.19.0 // indirect + golang.org/x/sync v0.5.0 // indirect + golang.org/x/tools v0.16.0 // indirect + kernel.org/pub/linux/libs/security/libcap/psx v1.2.69 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..5b12ec9 --- /dev/null +++ b/go.sum @@ -0,0 +1,46 @@ +github.com/BurntSushi/toml v0.4.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/nftables v0.1.0 h1:T6lS4qudrMufcNIZ8wSRrL+iuwhsKxpN+zFLxhUWOqk= +github.com/google/nftables v0.1.0/go.mod h1:b97ulCCFipUC+kSin+zygkvUVpx0vyIAwxXFdY3PlNc= +github.com/josharian/native v1.1.0 h1:uuaP0hAbW7Y4l0ZRQ6C9zfb7Mg1mbFKry/xzDAfmtLA= +github.com/josharian/native v1.1.0/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w= +github.com/mdlayher/netlink v1.7.2 h1:/UtM3ofJap7Vl4QWCPDGXY8d3GIY2UGSDbK+QWmY8/g= +github.com/mdlayher/netlink v1.7.2/go.mod h1:xraEF7uJbxLhc5fpHL4cPe221LI2bdttWlU+ZGLfQSw= +github.com/mdlayher/socket v0.5.0 h1:ilICZmJcQz70vrWVes1MFera4jGiWNocSkykwwoy3XI= +github.com/mdlayher/socket v0.5.0/go.mod h1:WkcBFfvyG8QENs5+hfQPl1X6Jpd2yeLIYgrGFmJiJxI= +github.com/miekg/dns v1.1.56 h1:5imZaSeoRNvpM9SzWNhEcP9QliKiz20/dA2QabIGVnE= +github.com/miekg/dns v1.1.56/go.mod h1:cRm6Oo2C8TY9ZS/TqsSrseAcncm74lfK5G+ikN2SWWY= +github.com/miekg/dns v1.1.57 h1:Jzi7ApEIzwEPLHWRcafCN9LZSBbqQpxjt/wpgvg7wcM= +github.com/miekg/dns v1.1.57/go.mod h1:uqRjCRUuEAA6qsOiJvDd+CFo/vW+y5WR6SNmHE55hZk= +github.com/vishvananda/netns v0.0.0-20180720170159-13995c7128cc h1:R83G5ikgLMxrBvLh22JhdfI8K6YXEPHx5P03Uu3DRs4= +golang.org/x/mod v0.13.0 h1:I/DsJXRlw/8l/0c24sM9yb0T4z9liZTduXvdAWYiysY= +golang.org/x/mod v0.13.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0= +golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= +golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= +golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c= +golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U= +golang.org/x/sync v0.4.0 h1:zxkM55ReGkDlKSM+Fu41A+zmbZuaPVbGMzvvdUPznYQ= +golang.org/x/sync v0.4.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.5.0 h1:60k92dhOjHxJkrqnwsfl8KuaHbn/5dl0lUPUklKo3qE= +golang.org/x/sync v0.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= +golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= +golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/tools v0.14.0 h1:jvNa2pY0M4r62jkRQ6RwEZZyPcymeL9XZMLBbV7U2nc= +golang.org/x/tools v0.14.0/go.mod h1:uYBEerGOWcJyEORxN+Ek8+TT266gXkNlHdJBwexUsBg= +golang.org/x/tools v0.16.0 h1:GO788SKMRunPIBCXiQyo2AaexLstOrVhuAL5YwsckQM= +golang.org/x/tools v0.16.0/go.mod h1:kYVVN6I1mBNoB1OX+noeBjbRk4IUEPa7JJ+TJMEooJ0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +honnef.co/go/tools v0.2.2/go.mod h1:lPVVZ2BS5TfnjLyizF7o7hv7j9/L+8cZY2hLyjP9cGY= +kernel.org/pub/linux/libs/security/libcap/cap v1.2.69 h1:N0m3tKYbkRMmDobh/47ngz+AWeV7PcfXMDi8xu3Vrag= +kernel.org/pub/linux/libs/security/libcap/cap v1.2.69/go.mod h1:Tk5Ip2TuxaWGpccL7//rAsLRH6RQ/jfqTGxuN/+i/FQ= +kernel.org/pub/linux/libs/security/libcap/psx v1.2.69 h1:IdrOs1ZgwGw5CI+BH6GgVVlOt+LAXoPyh7enr8lfaXs= +kernel.org/pub/linux/libs/security/libcap/psx v1.2.69/go.mod h1:+l6Ee2F59XiJ2I6WR5ObpC1utCQJZ/VLsEbQCD8RG24= diff --git a/lb.go b/lb.go new file mode 100644 index 0000000..5bdfb11 --- /dev/null +++ b/lb.go @@ -0,0 +1,1207 @@ +package main + +import ( + "errors" + "fmt" + "math/rand" + "net" + "os" + "strconv" + "strings" + "sync" + "time" + + "gopkg.in/yaml.v3" +) + +// Hardcoded settings +var ( + // Currently supported healtcheck protocol + supHcProto = map[hcProto]bool{ + hcProtoTcp: true, // TCP + } +) + +// Lobby doesn't implements the traffic load balancing. It orchestrates load balancer engines (lbe) for traffic load balancing +// An lbe is the runtime load balancer which performs the traffic load balancing +// More than one lbe can be operating simultaneously +// Each lbe has to implement the functions defined by the 'lbEngine' interface +// Which are required by Lobby for load balancing orchestration +type lbEngine interface { + checkDependencies() error // checks if the lb engine dependencies are satisfied + checkPermissions() error // checks if the lb runtime permissions are sufficient for the load balancer engine to function successfully + getCapabilities() map[lbProto]map[distMode]bool // returns lb capabilities. key protocols, value distribution mode + start(*lb) error // starts lb + stop() error // stops lb + reconfig(*lb) error // reconfigures lb + updateTarget(*target) error // updates given target + updateUpstream(*upstream, *[]net.IP) error // updates given upstream +} + +// Load balancer state +type lbState struct { + m sync.Mutex // load balancer changes mutex + wg *sync.WaitGroup // wait group to keep track of load balancer go routines + t bool // request to terminate +} + +// Load balancer +type lb struct { + targets []*target // list of all load balancer targets + e lbEngine // load balancer engine + et lbEngineType // load balancer engine type + upstreamIps *[]net.IP // list of all upstream IP addresses + state lbState // load balancer state +} + +// iota constants +type ( + distMode byte // distribution mode + lbEngineType uint8 // load balancer engine type + lbProto uint8 // load balancer protocol +) + +// distribution mode +const ( + distModeUnknown distMode = iota // undefined + distModeRR // round robin + distModeWeighted // weighted +) + +const ( + lbEngineUnknown lbEngineType = iota // undefined + lbEngineTest // test engine + lbEngineNft // nftables +) + +// Loadbalancer protocols +const ( + lbProtoUnknown lbProto = iota // undefined + lbProtoTcp // tcp + lbProtoUdp // udp + lbProtoSctp // sctp + lbProtoHttp // http +) + +// Load Balancer errors +var ( + errCheckDep = errors.New( + "Dependencies check failed", + ) + errCheckPerm = errors.New( + "Permissions check failed", + ) + errConfFileOpen = errors.New( + "Failed to open config file. Check if the file exists or read permissions", + ) + errConfFileUnmarshal = errors.New( + "Error when unmarshaling yaml config file", + ) + errConfRepEngine = errors.New( + "Error in configuration. Found repeated engine type. All config of a given engine type must be included in a single mapping", + ) + errConfRepTargetName = errors.New( + "Error in configuration. Found repeated target name. Every target name must be unique", + ) + errConfRepUGName = errors.New( + "Error in configuration. Found repeated upstream group name. Every upstream group name must be unique", + ) + errConfRepPortProto = errors.New( + "Error in configuration. Found repeated port/protocol. Each target must have a unique port/protocol pair", + ) + errConfRepUName = errors.New( + "Error in configuration. Found repeated upstream name. Every upstream name must be unique", + ) + errConfUHost = errors.New( + "Error in configuration. Found invalid host", + ) + errConfDnsAddr = errors.New( + "Error in configuration. Found invalid DNS address. All configured DNS addresses must be valid", + ) + errConfDistMode = errors.New( + "Error in configuration. Found unsupported distribution mode", + ) + errConfTargetProto = errors.New( + "Error in configuration. Found unsupported target protocol", + ) + errConfHcProtocol = errors.New( + "Error in configuration. Found unsupported upstream healthcheck protocol", + ) + errConfProbePort = errors.New( + "Error in configuration. Found problematic health check probe port", + ) + errConfProbeCI = errors.New( + "Error in configuration. Found problematic health check probe check interval", + ) + errConfProbeSC = errors.New( + "Error in configuration. Found problematic health check success count definition", + ) + errConfProbeTimeout = errors.New( + "Error in configuration. Found problematic health check timeout value", + ) + errDistMode = errors.New( + "distribution mode not found", + ) + errLbEngineType = errors.New( + "Error requesting unknown engine type", + ) + errLbInit = errors.New( + "Error during Load Balancer setup", + ) + errLbEngineStart = errors.New( + "Error during Load Balancer startup", + ) + errLbEngineStop = errors.New( + "Error during Load Balancer shutdown", + ) + errLbReconfig = errors.New( + "Error during Load Balancer reconfiguration", + ) + errLbEngineReconfig = errors.New( + "Error during Load Balancer engine reconfiguration", + ) + errLbCheckConf = errors.New( + "Error when checking Load Balancer configuration", + ) + errLbConf = errors.New( + "Error when loading Load Balancer configuration", + ) + errLbProto = errors.New( + "LB protocol not found", + ) + errLbEngineUpstreamUpdate = errors.New( + "Error during upstream update", + ) + errReplUpstreamIp = errors.New( + "Error occurred when replacing upstream IP's", + ) +) + +// getDistMode returns the distMode (distribution mode) from a string +func getDistMode(dm string) (distMode, error) { + switch dm { + case "round-robin": + return distModeRR, nil + case "weighted": + return distModeWeighted, nil + } + return distModeUnknown, fmt.Errorf("'%s' '%w'", dm, errDistMode) +} + +// returns the string value of the distMode (distribution mode) +func (dm distMode) String() string { + switch dm { + case distModeRR: + return "round-robin" + case distModeWeighted: + return "weighted" + } + return "unknown" +} + +// getLbEngineType returns the lbEngineType (load balancer engine type) from a string +func getLbEngineType(lbet string) (lbEngineType, error) { + switch lbet { + case "testEngine": + return lbEngineTest, nil + case "nftables": + return lbEngineNft, nil + } + return lbEngineUnknown, errLbEngineType +} + +// newLbEngine returns an initialized lb engine for the requested lbEngineType +func newLbEngine(lbet lbEngineType) (lbEngine, error) { + switch lbet { + case lbEngineTest: // test LB + return &testLb{}, nil + case lbEngineNft: // nftables + return &nft{}, nil + } + return nil, errLbEngineType +} + +// returns the string value of the lbEngineType (load balancer engine type) +func (lbet lbEngineType) String() string { + switch lbet { + case lbEngineTest: + return "testEngine" + case lbEngineNft: + return "nftables" + } + + return "unknown" +} + +// getLbProtocol returns the getLbProtocol (load balancer protocol) from a string +func getLbProtocol(lbp string) (lbProto, error) { + switch lbp { + case "tcp": + return lbProtoTcp, nil + case "udp": + return lbProtoUdp, nil + case "sctp": + return lbProtoSctp, nil + case "http": + return lbProtoHttp, nil + } + + return lbProtoUnknown, fmt.Errorf("'%s' '%w'", lbp, errLbProto) +} + +// returns the string value of the lbProto (load balancer protocol) +func (lbp lbProto) String() string { + switch lbp { + case lbProtoTcp: + return "tcp" + case lbProtoUdp: + return "udp" + case lbProtoSctp: + return "sctp" + } + return "unknown" +} + +// addUpstreamIps adds the provided IP address to the lb.upstreamIps slice +// holding all load blancer upstream IP addresses +func (l *lb) addUpstreamIps(nip net.IP) { + if nip == nil { + return + } + + *l.upstreamIps = append(*l.upstreamIps, nip) + + return +} + +// replaceUpstreamIps replaces an IP in the lb.upstreamIps slice +// 'oip' is the IP to be removed and 'nip' is the new IP to take its place +// As this slice should have all IP's from all upstreams for the load balancer, +// the IP to be removed is expected to exist +// In case the 'oip' is not found the error errReplUpstreamIp is returned +func (l *lb) replaceUpstreamIps(oip *net.IP, nip *net.IP) error { + uip := *l.upstreamIps + for i, ip := range uip { + if ip.Equal(*oip) { + uip[i] = *nip + return nil + } + } + + return fmt.Errorf( + "%w: Failed to replace '%s' with '%s' in upstream IP's list. '%s' not found", + errReplUpstreamIp, + oip.String(), + nip.String(), + oip.String(), + ) +} + +// checkConfig checks the configuration file +// It verifies that: +// - only supported engine types are configured +// - all engine types, target, upstream group and upstream names are unique +// - targets do not have conflicting port/protocol configuration +// - target protocols are supported by the engine +// - the configured distribution mode is supported as defined in global var supDM +// - the host format is valid +// - upstream healtcheck protocols are supported +// - DNS addresses are valid +// +// It returns a errLbCheckConf error if check fails +func checkConfig(configYaml *ConfigYaml) error { + LogDVf("LB: configuration check") + var ( + eNames []string + tNames []string + uNames []string + ugNames []string + ) + + for i, lbc := range configYaml.LbConfig { + // Check load balancer engine + lbE, err := getLbEngineType(lbc.Engine) + if err != nil { + return fmt.Errorf("%w: %w", errLbCheckConf, err) + } + lben := lbE.String() + + LogDVf("LB: checking '%s' load balancer engine uniqueness", lben) + if i == 0 { + // Initialize temporary var + eNames = append(eNames, lben) + } else { + for _, en := range eNames { + if en == lben { + LogDf("LB: found a repeated engine type: %s", lbc.Engine) + return fmt.Errorf("%w: %w: problematic engine in config: %s", errLbCheckConf, errConfRepEngine, lbc.Engine) + } + } + } + + e, _ := newLbEngine(lbE) + ec := e.getCapabilities() + + // Create a map with the port number as the key and slice of strings for the protocol value + portMap := make(map[uint16][]string) + + for i, t := range lbc.TargetsConfig { + LogDVf("LB: target '%s' check", t.Name) + if i == 0 { + // Initialize temporary vars + tNames = append(tNames, t.Name) + portMap[t.Port] = append(portMap[t.Port], t.Protocol) + ugNames = append(ugNames, t.UpstreamGroup.Name) + } else { + // Check if target names are unique + for _, tn := range tNames { + if tn == t.Name { + LogDf("LB: found a repeated target name: %s", tn) + return fmt.Errorf("%w: %w: problematic target name in config: %s", errLbCheckConf, errConfRepTargetName, tn) + } + } + tNames = append(tNames, t.Name) + + // Check if target protocol and port are unique + if protos, ok := portMap[t.Port]; ok { + LogDVf("Value: %v", protos) + for _, proto := range protos { + if proto == t.Protocol { + LogDf("Found a repeated target port/protocol configuration: %d/%s", t.Port, t.Protocol) + return fmt.Errorf("%w: %w: Problematic port/protocol: %d/%s", errLbCheckConf, errConfRepPortProto, t.Port, t.Protocol) + } + } + } else { + portMap[t.Port] = append(portMap[t.Port], t.Protocol) + } + + // Check if upstreamGroup names are unique + for _, ugn := range ugNames { + if ugn == t.UpstreamGroup.Name { + LogDf("LB: found a repeated upstream group name in config: %s", ugn) + return fmt.Errorf("%w: %w: Problematic upstream group name: %s", errLbCheckConf, errConfRepUGName, ugn) + } + } + ugNames = append(ugNames, t.UpstreamGroup.Name) + + } + + // Check target protocol + tP, err := getLbProtocol(t.Protocol) + if err != nil { + return fmt.Errorf("%w: %w", errLbCheckConf, err) + } + if _, ok := ec[tP]; !ok { + var supP []string + for p := range ec { + supP = append(supP, fmt.Sprintf("'%s'", p.String())) + } + return fmt.Errorf( + "%w: %w: unsupported protocol '%s' for target '%s'. Chose one of the supported protocols: %s", + errLbCheckConf, + errConfTargetProto, + t.Protocol, + t.Name, + strings.Join(supP, ", "), + ) + } + + // Check if upstreamGroup distribution mode is supported + dMode, _ := getDistMode(t.UpstreamGroup.Distribution) + if _, ok := ec[tP][dMode]; !ok { + var supDms []string + for dm := range ec[tP] { + supDms = append(supDms, fmt.Sprintf("'%s'", dm.String())) + } + return fmt.Errorf( + "%w: %w: unsupported distribution mode: %s. Chose one of the supported modes: %s", + errLbCheckConf, + errConfDistMode, + t.UpstreamGroup.Distribution, + strings.Join(supDms, ", "), + ) + } + + // Check upstreams + for _, u := range t.UpstreamGroup.Upstreams { + LogDVf("LB: upstream '%s' check", u.Name) + + // Check if upstream names are unique + if len(uNames) == 0 { + uNames = append(uNames, u.Name) + } else { + for _, un := range uNames { + if un == u.Name { + LogDf("Found a repeated upstream name: %s", un) + return fmt.Errorf("%w: %w: Problematic upstream name: %s", errLbCheckConf, errConfRepUName, un) + } + } + uNames = append(uNames, u.Name) + } + + // Check upstream host + uh, _ := getHostType(u.Host) + if uh == hostTypeUnknown { + return fmt.Errorf( + "%w: %w: host '%s' for upstream '%s' is invalid. Set a valid host in the FQDN, IPv4 or IPv6 format", + errLbCheckConf, + errConfUHost, + u.Host, + u.Name, + ) + } + + // Check upstream healthcheck: + // - protocol + // - port + // - check_interval + // - success_count + // - timeout + if u.HealthCheck != (HealthCheckConfig{}) { + hcP, _ := getHcProto(u.HealthCheck.Protocol) + if _, ok := supHcProto[hcP]; !ok { + var supHcP []string + for p := range supHcProto { + supHcP = append(supHcP, fmt.Sprintf("'%s'", p.String())) + } + return fmt.Errorf( + "%w: %w: unsupported upstream healthcheck protocol '%s' for upstream '%s'. Chose one of the supported protocols: %s", + errLbCheckConf, + errConfHcProtocol, + u.HealthCheck.Protocol, + u.Name, + strings.Join(supHcP, ", "), + ) + } + + if u.HealthCheck.Port == 0 { + return fmt.Errorf("%w: %w: health check probe 'port' for upstream '%s' must be correctly defined", + errLbCheckConf, + errConfProbePort, + u.Name) + } + + if u.HealthCheck.Probe.CheckInterval == 0 { + return fmt.Errorf("%w: %w: health check probe 'check_interval' for upstream '%s' must be defined", + errLbCheckConf, + errConfProbeCI, + u.Name) + } + + if u.HealthCheck.Probe.Count == 0 { + return fmt.Errorf("%w: %w: health check probe 'success_count' for upstream '%s' must be defined", + errLbCheckConf, + errConfProbeSC, + u.Name) + } + + if u.HealthCheck.Probe.Timeout == 0 { + return fmt.Errorf("%w: %w: health check probe 'timeout' for upstream '%s' must be defined", + errLbCheckConf, + errConfProbeTimeout, + u.Name) + } + } + + // Check DNS addresses + for _, a := range u.Dns.Servers { + if net.ParseIP(a) == nil { + LogDf("Invalid DNS address: %s", a) + return fmt.Errorf( + "%w: %w: Problematic DNS address: %s", + errLbCheckConf, + errConfDnsAddr, + a, + ) + } + } + } + + } + } + + return nil +} + +// getConfig parses the load balancer engine configuration +// It assumes that the config has been already checked for errors or mistakes +// Returns an error in case of failure +func (l *lb) getConfig(lbc *LbConfig) error { + if et, err := getLbEngineType(lbc.Engine); err != nil { + return fmt.Errorf("%w: %w", errLbConf, err) + } else { + l.et = et + } + + // For each target + for _, t := range lbc.TargetsConfig { + // Distribution mode config + dMode, err := getDistMode(t.UpstreamGroup.Distribution) + if err != nil { + return fmt.Errorf("%w: %w", errLbConf, err) + } + + // Initalize Upstream Group + ug := upstreamGroup{ + name: t.UpstreamGroup.Name, + distMode: dMode, + failoverMode: ugFoModeInactive, + } + + // Target protocol config + lbp, _ := getLbProtocol(t.Protocol) + + // For each upstream + for _, u := range t.UpstreamGroup.Upstreams { + var ( + hcActive bool + hcProto hcProto + uStartAvailable bool + ) + + // Upstream health check + if u.HealthCheck != (HealthCheckConfig{}) { + hcActive = true + uStartAvailable = u.HealthCheck.StartAvailable + hcProto, _ = getHcProto(u.HealthCheck.Protocol) + } else { + // Health check inactive for this upstream + hcActive = false + uStartAvailable = true + } + + // Upstream IP Address + var ipa net.IP + var lttl uint32 + ht, _ := getHostType(u.Host) + switch ht { + case hostTypeFqdn: + var err error + + // If host is a name, set it as a FQDN with trailing dot + if !strings.HasSuffix(u.Host, ".") { + u.Host = u.Host + "." + } + + // Get A Record IP address for FQDN + // If it fails to resolve, set IP Address to nil and do not start available + // In case of failure, a new DNS query will be performed in the configured + // ttl or in the default ttl + ipa, lttl, err = resolveFqdn(u.Host, u.Dns.Servers, nil) + if err != nil { + LogWf( + "LB: failed to resolve IP for host '%s' for upstream '%s'", + u.Host, + u.Name, + ) + if u.Dns.Ttl != 0 { + lttl = u.Dns.Ttl + } else { + lttl = lobbySettings.defaultDnsTtl + } + LogWf( + "LB: setting upstream '%s' as unavailable. New DNS query query will be performed in '%d' seconds", + u.Name, + lttl, + ) + ipa = nil + uStartAvailable = false + } + LogDVf("LB: Initial FQDN upstream address '%s' and DNS TTL %ds", ipa.String(), lttl) + case hostTypeIPv4, hostTypeIPv6: + ipa = net.ParseIP(u.Host) + default: + LogWf("LB: failed to process host '%s' for upstream '%s'", u.Host, u.Name) + LogWf("LB: setting upstream '%s' as unavailable", u.Name) + ipa = nil + uStartAvailable = false + } + LogDf("LB: upstream '%s' address: '%s'", u.Name, ipa) + + // Add upstream IP Address to load balancer list of IP addresses + l.addUpstreamIps(ipa) + + // Upstream initialization + newUpstream := upstream{ + name: u.Name, + protocol: lbp, + host: u.Host, + port: u.Port, + dns: upstreamDns{ + addresses: u.Dns.Servers, + confTtl: u.Dns.Ttl, + ttl: lttl, + chDcStop: make(chan struct{}), + }, + address: ipa, + available: uStartAvailable, + healthCheck: healthCheck{ + active: hcActive, + protocol: hcProto, + port: u.HealthCheck.Port, + checkInterval: u.HealthCheck.Probe.CheckInterval, + timeout: u.HealthCheck.Probe.Timeout, + countConfig: u.HealthCheck.Probe.Count, + count: 0, + chHcStop: make(chan struct{}), + }, + } + + // Add upstream to upstream group + ug.upstreams = append(ug.upstreams, &newUpstream) + } + + // Target initialization + newTarget := target{ + name: t.Name, + protocol: lbp, + ip: t.Ip, + port: t.Port, + upstreamGroup: &ug, + } + + l.targets = append(l.targets, &newTarget) + } + + return nil +} + +// stopHcs stops the load balancer engine healthchecks +// The stop is triggered when the healthcheck channel is closed +func (l *lb) stopHcs() { + for _, t := range l.targets { + for _, u := range t.upstreamGroup.upstreams { + close(u.healthCheck.chHcStop) + } + } +} + +// stopDcs stops the load balancer engine DNS checks +// The stop is triggered when the DNS check channel is closed +func (l *lb) stopDcs() { + for _, t := range l.targets { + for _, u := range t.upstreamGroup.upstreams { + close(u.dns.chDcStop) + } + } +} + +// stopChecks requests the healthcheck and DNS checks to stop +// It uses waitgroups to wait until all are stopped and only then it returns +func (l *lb) stopChecks() { + LogIf("LB: stopping health checks for '%s'", l.et.String()) + l.stopHcs() + LogIf("LB: stopping dns checks") + l.stopDcs() + l.state.wg.Wait() + LogDf("LB: health checks and dns checks stopped") +} + +// startChecks initializes the DNS checks and health checks for all upstreams +func (l *lb) startChecks() { + LogIf("LB: starting checks for '%s'", l.et.String()) + + // For each target + for _, t := range l.targets { + // For each upstream: initalize dns and health checks + for _, u := range t.upstreamGroup.upstreams { + LogDVf("LB: initializing DNS checks for target '%s'", t.name) + ht, _ := getHostType(u.host) + if ht == hostTypeFqdn { + u.dns.chDcStop = make(chan struct{}) + l.initDnsCheck(u) + } + LogDVf("LB: initializing health checks for target '%s'", t.name) + if u.healthCheck.active { + l.initHealthCheck(u, t) + } + } + } +} + +// stop is used to stop the load balancer engine +// It stops all load balancers checks and then +// requests the load balancer engine to stop +func (l *lb) stop() error { + LogIf("LB: stop Load Balancer requested") + l.stopChecks() + if err := l.e.stop(); err != nil { + return fmt.Errorf("%w: %w", errLbEngineStop, err) + } + LogIf("LB: load balancer successfully stopped") + return nil +} + +// start function starts the load balancer engine +// It initializes the engine, requests the engine to confirm permissions and dependencies +// Then it starts load balancing and finally the DNS and health checks are initiated +// All errors are treated as non recoverable functional impediment +func (l *lb) start() error { + LogIf("LB: start Load Balancer requested") + + // initialize load balancer engine + LogDf("LB: initializing load balancer engine '%s'", l.et.String()) + if err := l.e.checkPermissions(); err != nil { + return fmt.Errorf("%w: %w: %w", errLbEngineStart, errCheckPerm, err) + } + + if err := l.e.checkDependencies(); err != nil { + return fmt.Errorf("%w: %w: %w", errLbEngineStart, errCheckDep, err) + } + + if err := l.e.start(l); err != nil { + return fmt.Errorf("%w: %w", errLbEngineStart, err) + } + + l.startChecks() + + LogIf("LB: the load balancer was successfully started") + + return nil +} + +// initHealthCheck initiates the healthcheck routines for the upstream +func (l *lb) initHealthCheck(u *upstream, t *target) { + e := l.e + ln := l.et.String() + + // Initialize the random number generator with a seed based on the current time + r := rand.New(rand.NewSource(time.Now().UnixNano())) + + // Generate a random number between 1 and maxHcTimerInit (inclusive) + // Intn generates 0 which we don't want, hence 1 + r.Intn + initTicker := 1 + r.Intn(lobbySettings.maxHcTimerInit) + + // Initialize ticker with a random duration to avoid all healthchecks starting at the same millisecond + LogDVf("LB HC (%s): upstream healthcheck timer initialized to %dms", u.name, initTicker) + u.healthCheck.ticker = time.NewTicker(time.Duration(initTicker) * time.Millisecond) + + l.state.wg.Add(1) + go func() { + defer l.state.wg.Done() + + for { + select { + case <-u.healthCheck.chHcStop: + LogIf("LB HC (%s): healthcheck stop requested", u.name) + + // complete go routine + return + case <-u.healthCheck.ticker.C: + LogDVf("LB HC (%s): healthcheck timer trigger", u.name) + + l.state.m.Lock() // This has been included to be able to pause the healthcheck for instance in case of reconfiguration + LogDVf("LB: '%s' load balancer engine changes are locked", ln) + // Check if the load balancer is in 'terminate' state + // if it is skip the healthcheck + if l.state.t { + l.state.m.Unlock() + LogDVf("LB: '%s' load balancer engine changes are unlocked", ln) + continue + } + LogDVf("LB: '%s' load balancer engine changes are unlocked", ln) + l.state.m.Unlock() + + var addr string + if u.address != nil { + addr = u.address.String() + ":" + strconv.Itoa(int(u.healthCheck.port)) + } else { + LogDVf("LB HC (%s): Host '%s' with unresolved address. Health check paused while host address is not available", u.name, u.host) + } + + LogDVf( + "LB HC (%s): healthchecking upstream IP: '%s'; Port: '%d'; Protocol: '%s'; Timeout: '%d' seconds", + u.name, + u.address.String(), + u.healthCheck.port, + u.healthCheck.protocol.String(), + u.healthCheck.timeout, + ) + c, err := net.DialTimeout( + u.healthCheck.protocol.String(), + addr, + time.Duration(u.healthCheck.timeout)*time.Second, + ) + if err != nil { + LogIf( + "LB HC (%s): healthcheck for upstream failed. Retrying in %ds. Error: %v", + u.name, + u.healthCheck.checkInterval, + err, + ) + + // Reset health_check count to 0 + u.healthCheck.count = 0 + + if u.available { + // If upstream was marked as available + u.available = false + LogIf( + "LB HC (%s): upstream became unavailable due to health_check failure", + u.name, + ) + // update nftables + e.updateTarget(t) + } + } else { + // If health_check succeeds, close net.Conn + c.Close() + + if !u.available { + // If upstream in not available state + // Increment health_check count + u.healthCheck.count++ + LogIf("LB HC (%s): upstream is unavailable at '%s', but health_check succeeded. %d/%d tests succeeded", u.name, addr, u.healthCheck.count, u.healthCheck.countConfig) + if u.healthCheck.count >= u.healthCheck.countConfig { + u.available = true + LogIf("LB HC (%s): upstream became available at '%s'", u.name, addr) + // update nftables + e.updateTarget(t) + } + } else { + LogDVf("LB HC (%s): upstream continues available at %s", u.name, addr) + } + } + // Reset healthcheck timer + LogDVf( + "LB HC (%s): healthcheck recheck in %ds", + u.name, + u.healthCheck.checkInterval, + ) + u.healthCheck.ticker.Reset(time.Duration(u.healthCheck.checkInterval) * time.Second) + } + } + }() +} + +// initDnsCheck initiates the DNS check routines for the upstream +func (l *lb) initDnsCheck(u *upstream) { + ln := l.et.String() + + if u.dns.confTtl != 0 { + LogDVf("LB DNS (%s): using upstream config DNS ttl %ds", u.name, u.dns.confTtl) + u.dns.ttl = u.dns.confTtl + u.dns.ticker = time.NewTicker(time.Duration(u.dns.ttl) * time.Second) + } else { + if u.dns.ttl == 0 { + u.dns.ttl = lobbySettings.defaultDnsTtl + } + LogDVf("LB DNS (%s): using DNS ttl %ds", u.name, u.dns.ttl) + u.dns.ticker = time.NewTicker(time.Duration(u.dns.ttl) * time.Second) + } + + l.state.wg.Add(1) + go func() { + defer l.state.wg.Done() + + for { + select { + case <-u.dns.chDcStop: + LogIf("LB DNS (%s): DNS check stop requested", u.name) + return + case <-u.dns.ticker.C: + + // This has been included to be able to pause the healthcheck for instance in case of reconfiguration + l.state.m.Lock() + LogDVf("LB: '%s' load balancer engine changes are locked", ln) + // Check if the load balancer is in 'terminate' state + // if it is skip the healthcheck + if l.state.t { + l.state.m.Unlock() + LogDVf("LB: '%s' load balancer engine changes are unlocked", ln) + continue + } + LogDVf("LB: '%s' load balancer engine changes are unlocked", ln) + l.state.m.Unlock() + + ua := u.address + rua, ttl, err := resolveFqdn(u.host, u.dns.addresses, nil) + if err != nil { + LogWf("LB DNS (%s): failed to resolve '%s'", u.name, u.host) + LogWf( + "LB DNS (%s): upstream address will be kept on the last known A Record: '%s'", + u.name, + ua.String(), + ) + if u.dns.ttl == 0 { + u.dns.ttl = lobbySettings.defaultDnsTtl + } + LogWf("LB DNS (%s): new DNS query in '%d's", u.name, u.dns.ttl) + u.dns.ticker.Reset(time.Duration(u.dns.ttl) * time.Second) + continue + } + if !ua.Equal(rua) { + LogIf( + "LB DNS (%s): upstream IP address changed based on DNS query from '%s' to '%s'", + u.name, + ua, + rua, + ) + + // In case health check is not active for upstream, + // set the upstream as available. Upstreams without health checks + // are always considered to be available unless there were DNS + // issues during setup or reconfiguration + if !u.healthCheck.active { + u.available = true + } + + // update load balancer upstream + if err = l.updateUpstream(u, &rua); err != nil { + LogWf( + "LB DNS (%s): upstream update request failed. This is not expected and the load balancer might be misconfigured as a result. Manual troubleshooting is likely required. Consider increasing load balancer verbosity to troubleshoot further", + u.name, + ) + LogIf("LB DNS (%s): %v", u.name, err) + } + } + if u.dns.confTtl == 0 { + LogDVf( + "LB DNS (%s): DNS TTL was not configured to be overriden. Using DNS resolved TTL", + u.name, + ) + if ttl != 0 { + u.dns.ttl = ttl + } else { + LogDVf( + "LB DNS (%s): Resolved DNS TTL was '0'. Using %d this time", + u.name, + lobbySettings.defaultDnsTtl, + ) + u.dns.ttl = lobbySettings.defaultDnsTtl + } + u.dns.ticker.Reset(time.Duration(u.dns.ttl) * time.Second) + LogDf("LB DNS (%s): next DNS check to be performed in %d seconds", u.name, u.dns.ttl) + } + } + } + }() +} + +// reconfig implements the load balancer reconfiguration procedure +// After locking any other load balancer changes, +// it initiates a new load balancer engine and +// requests the previous load balancer engine reconfiguration +// Then it starts the new load balancer DNS and health checks +// Sets the previous load balancer state to 'terminate' and unlocks load balancer changes +// Finally, it requests the previous load balancer DNS and health checks to be stopped +// If the new load balancer engine initialization or +// the reconfiguration returns an error, then the reconfig method doesn't proceeds and +// it unlocks the previous load balancer changes and returns the errLbEngineReconfig error +func (l *lb) reconfig(nl *lb) error { + ln := l.et.String() + LogIf("LB: '%s' load balancer engine reconfiguration", ln) + + l.state.m.Lock() + LogDVf("LB: '%s' load balancer engine changes are locked", ln) + + var err error + + if nl.e, err = newLbEngine(nl.et); err != nil { + l.state.m.Unlock() + LogDVf("LB: '%s' load balancer engine changes are unlocked", ln) + return fmt.Errorf("%w: %w", errLbEngineReconfig, err) + } + + if err = l.e.reconfig(nl); err != nil { + l.state.m.Unlock() + LogDVf("LB: '%s' load balancer engine changes are unlocked", ln) + return fmt.Errorf("%w: %w", errLbEngineReconfig, err) + } + + nl.startChecks() + + l.state.t = true + l.state.m.Unlock() + LogDVf("LB: '%s' load balancer engine state set to 'terminate' and changes are unlocked", ln) + l.stopChecks() + + return nil +} + +// updateUpstream performs the necessary tasks to refresh an upstream +// given a new upstream IP address +// - replaces the upstream address with the new IP address +// - updates the load balancer list of all upstream IP addresses is updated +// - calls the LB engine upstream update with a list of unique upstream IP addresses for the load balancer +func (l *lb) updateUpstream(u *upstream, nua *net.IP) error { + LogIf("LB: update upstream for '%s'", u.name) + + ln := l.et.String() + + // Lock load balancer mutex + l.state.m.Lock() + defer l.state.m.Unlock() + LogDVf("LB: '%s' load balancer engine changes are locked", ln) + + oua := u.address + LogDf("LB: replacing upstream address from '%s' to '%s'", oua.String(), nua.String()) + u.address = *nua + + LogDf("LB: updating load balancer slice of all upstream IPs") + if oua != nil { + LogDVf("LB: replacing upstream IP '%s' with '%s'", oua.String(), nua.String()) + if err := l.replaceUpstreamIps(&oua, nua); err != nil { + LogWf( + "Upstream DNS: replace upstream IP failed. This is odd and it could be a bug with impact on the Load Balancer functionality. Please report with full logs of load balancer running with VerboseDebug verbosity. %v", + err, + ) + l.addUpstreamIps(*nua) + } + } else { + l.addUpstreamIps(*nua) + } + + LogDf("LB: refreshing load balancer engine for upstream '%s'", u.name) + unipl := findUniqueNetIp(l.upstreamIps) + if err := l.e.updateUpstream(u, &unipl); err != nil { + LogDVf("LB: '%s' load balancer engine changes are unlocked", ln) + return fmt.Errorf("%w: %w", errLbEngineUpstreamUpdate, err) + } + + LogDVf("LB: '%s' load balancer engine changes are unlocked", ln) + return nil +} + +// lbInit creates and returns a set of load balancer engines +// It reads the config file, checks for config errors +// and loads the config for each load balancer engine +// It returns a pointer to the new LB instances or an errLbNew error +func lbInit() ([]*lb, error) { + ls := []*lb{} + LogIf("LB: config file path: %s", lobbySettings.configFilePath) + + // Initialize Routing Config + configYaml := ConfigYaml{} + + // Get Routing Config file to byte slice + configFile, err := os.ReadFile(lobbySettings.configFilePath) + if err != nil { + LogIf("LB: Failed to open local config file at '%s'. Will try at system location at '%s' next", lobbySettings.configFilePath, lobbySettings.systemConfigFilePath) + configFile, err = os.ReadFile(lobbySettings.systemConfigFilePath) + if err != nil { + return ls, fmt.Errorf("%w: %w: failed to open config file in '%s' and '%s'", errLbInit, errConfFileOpen, lobbySettings.configFilePath, lobbySettings.systemConfigFilePath) + } + } + + // Unmarshal Routing Config YAML file + if err = yaml.Unmarshal(configFile, &configYaml); err != nil { + return ls, fmt.Errorf("%w: %w: %w", errLbInit, errConfFileUnmarshal, err) + } + + // Check configuration + if err = checkConfig(&configYaml); err != nil { + return ls, fmt.Errorf("%w: %w", errLbInit, err) + } + + for _, lbc := range configYaml.LbConfig { + l := &lb{} + l.upstreamIps = &[]net.IP{} + l.state.wg = &sync.WaitGroup{} + + // Parse and load the configuration + if err := l.getConfig(&lbc); err != nil { + return ls, fmt.Errorf("%w: %w", errLbInit, err) + } + + if l.e, err = newLbEngine(l.et); err != nil { + return ls, fmt.Errorf("%w: %w", errLbInit, err) + } + + ls = append(ls, l) + } + + return ls, nil +} + +// lbsCompare compares two slices of load balancers based on their lbEngineType and returns: +// - a map with the load balancers that are on both slices (kept) +// - a slice with the load balancers that are on nlbs, but not on olbs (added) +// - a slice with the load balancers that are on olbs, but not on nlbs (removed) +// +// The map has the lbEngineType as key where the value is a slice of two load balancers [0] olbs and [1] nlbs +func lbsCompare(olbs, nlbs []*lb) (*map[lbEngineType][]*lb, []*lb, []*lb) { + var added []*lb // holds the lb with the respective lbEngineType found on nlbs, but not on olbs + var removed []*lb // holds the lb with the respective lbEngineType found on olbs, but not on nlbs + + // map holding the lb with engine type found on nlbs and olbs + // the key is the lbEngineType and the value is a slice of lb + // [0] holds the respective olbs lb + // [1] holds the respective nlbs lb + kept := make(map[lbEngineType][]*lb) + +olbsLoop: + for _, o := range olbs { // loop through all olbs elements + for _, n := range nlbs { // for each olbs element, loop through all nlbs elements + if o.et == n.et { // if olbs element and nlbs element have the same lbEngineType + var k []*lb // create a slice of load balancers to hold each lb + k = append(k, o, n) // add the olbs element and then add the nlbs element + kept[o.et] = k // register the new lb slice to the 'kept' map + LogDVf("LB: load balancer '%s' added to the 'kept' map", o.et.String()) + continue olbsLoop // as a match was found, continue to the next olbs and interrupt nlbs loop + } + } + // as this olbs element lbEngineType wasn't found on any nlbs element: + removed = append(removed, o) + LogDVf("LB: load balancer '%s' added to the 'removed' slice", o.et.String()) + } + + // now that we have completed the kept and removed ones, we need to check the ones that are on nlbs and not on olbs (added) + for _, n := range nlbs { // loop through all nlbs elements + if _, ok := kept[n.et]; !ok { // if the nlbs element lbEngineType is not present in kept, it means it is not found in olbs + added = append(added, n) + LogDVf("LB: load balancer '%s' added to the 'added' slice", n.et.String()) + } + } + + return &kept, added, removed +} + +// lbReconfig is used to manage the reconfiguration of the load balancer engines +// A new slice of load balancers will be initialized, where the config is checked and loaded +// The previous and the new load balancer engine slices are compared to identify which +// load balancer engines have been removed, added or kept +// For the ones that have been kept, it is requested to the load balancer engine to reconfigure them +// The added ones are started and the removed ones are stopped +func lbReconfig(lbs *[]*lb) error { + LogIf("LB: reconfiguration of all load balancers has been requested") + LogDf("LB: requesting the initialization of the new load balancers engine set") + nlbs, err := lbInit() + if err != nil { + return fmt.Errorf("%w: %w", errLbReconfig, err) + } + + // Identifying the load balancers that are on the old and new config, + // that are on the new config, but are not on the old and + // that are not on the new config, but are on the old + toKeep, toAdd, toRem := lbsCompare(*lbs, nlbs) + + for _, l := range *toKeep { // for each lb engine that is on both old and new config + LogIf("LB: load balancer '%s' configuration will be refreshed", l[0].et.String()) + if err := l[0].reconfig(l[1]); err != nil { + return fmt.Errorf("%w: %w", errLbReconfig, err) + } + } + + if len(toAdd) > 0 { + for _, l := range toAdd { // for each lb engine that is on the new config, but not on the old + LogIf("LB: load balancer '%s' has been added to configuration and will be started", l.et.String()) + if err := l.start(); err != nil { + return fmt.Errorf("%w: %w", errLbReconfig, err) + } + } + } + + if len(toRem) > 0 { + for _, l := range toRem { // for each lb engine that is on the old config, but not on the new + LogIf("LB: load balancer '%s' no longer configured will be stopped", l.et.String()) + if err := l.stop(); err != nil { + return fmt.Errorf("%w: %w", errLbReconfig, err) + } + } + } + + *lbs = nlbs // update var holding load balancers replacing the old with the new one + + return nil +} diff --git a/lb_norace_test.go b/lb_norace_test.go new file mode 100644 index 0000000..57fd2c9 --- /dev/null +++ b/lb_norace_test.go @@ -0,0 +1,167 @@ +//go:build !race +// +build !race + +package main + +import ( + "fmt" + "net" + "os" + "testing" + "time" +) + +// can't test with -race flag due to defaultDnsResolver change + +func TestLbChanges(t *testing.T) { + config := `lb: + - engine: testEngine # load balancer engine. only 'nftables' supported for now + targets: + - name: TestInitDnsCheck # target name + protocol: tcp # transport protocol. only tcp supported for now + port: 8082 # target port + upstream_group: # upstream_group to be used for target + name: ug # upstream_group name + distribution: round-robin # ug traffic distribution mode. only round-robin supported for now + upstreams: + - name: lt8082 # upstream name + host: lobby-test.ipbuff.com # upstream host. IP Address or domain name + port: 8082 # upstream port + dns: # include in case you want to use specific DNS to resolve the fqdn host address. If host is IPv4 or IPv6 this setting will not have any effect. In case this mapping is not present the OS resolvers will be used + servers: # dns address list. Queries will be done sequentially in case of failure + - 1.1.1.1 # cloudflare IPv4 DNS + - 2606:4700::1111 # cloudflare IPv6 DNS + ttl: 1 # custom ttl can be specified to overwrite the DNS response TTL + health_check: # don't include the health_check mapping or leave it empty to disable health_check. upstreams will be considered always as active when health_checks are not enabled + protocol: tcp # health-heck protocol. only tcp supported for now + port: 8082 # health_check port + start_available: true # set 'true' if upstream should be considered as available at start. set 'false' otherwise + probe: + check_interval: 1 # seconds. Max value: 65536 + timeout: 2 # seconds. Max value: 256 + success_count: 3 # amount of successful health checks to become active +` + + testConfigPath := "/tmp/lobby_test_conf.yaml" + lobbySettings.configFilePath = testConfigPath + os.WriteFile(testConfigPath, []byte(config), 0400) + + // mock dns resolvers + var mockResolverSucceed resolver = func(f string, dnsa []string) (net.IP, uint32, error) { + return net.ParseIP("1.1.1.1"), 600, nil + } + var mockResolverFail resolver = func(f string, dnsa []string) (net.IP, uint32, error) { + return net.ParseIP("1.1.1.1"), 600, fmt.Errorf("Some DNS error") + } + + wt := 6 + + // start by testing dns check fail on boot + defaultDnsResolver = mockResolverFail + + lbs, err := lbInit() + if err != nil { + t.Errorf("lbInit returned an unexpected error: '%v'", err) + } + for _, l := range lbs { + if err := l.start(); err != nil { + t.Errorf("lb start returned an unexpected error: '%v'", err) + } + } + os.Remove(testConfigPath) + + t.Logf("waiting %ds for LB to boot", wt) + time.Sleep(time.Duration(wt) * time.Second) + + // test success + defaultDnsResolver = mockResolverSucceed + t.Logf("waiting %ds for DNS check and upstream update to complete", wt) + time.Sleep(time.Duration(wt) * time.Second) + defaultDnsResolver = miekgResolver + t.Logf("waiting %ds for DNS check and upstream update to complete", wt) + time.Sleep(time.Duration(wt) * time.Second) + // test failure + defaultDnsResolver = mockResolverFail + t.Logf("waiting %ds for DNS check and upstream update to complete", wt) + time.Sleep(time.Duration(wt) * time.Second) + + defaultDnsResolver = miekgResolver + lbs[0].stop() +} + +func Test0TTL(t *testing.T) { + config := `lb: + - engine: testEngine # load balancer engine. only 'nftables' supported for now + targets: + - name: TestInitDnsCheck # target name + protocol: tcp # transport protocol. only tcp supported for now + port: 8082 # target port + upstream_group: # upstream_group to be used for target + name: ug # upstream_group name + distribution: round-robin # ug traffic distribution mode. only round-robin supported for now + upstreams: + - name: lt8082 # upstream name + host: lobby-test.ipbuff.com # upstream host. IP Address or domain name + port: 8082 # upstream port + dns: # include in case you want to use specific DNS to resolve the fqdn host address. If host is IPv4 or IPv6 this setting will not have any effect. In case this mapping is not present the OS resolvers will be used + servers: # dns address list. Queries will be done sequentially in case of failure + - 1.1.1.1 # cloudflare IPv4 DNS + - 2606:4700::1111 # cloudflare IPv6 DNS +` + + testConfigPath := "/tmp/lobby_test_conf.yaml" + lobbySettings.configFilePath = testConfigPath + os.WriteFile(testConfigPath, []byte(config), 0400) + + // mock dns resolvers + var mockResolverSucceed resolver = func(f string, dnsa []string) (net.IP, uint32, error) { + return net.ParseIP("1.1.1.1"), 1, nil + } + var mockResolver0TTL resolver = func(f string, dnsa []string) (net.IP, uint32, error) { + return net.ParseIP("1.1.1.1"), 0, nil + } + + wt := 3 + + defaultDnsResolver = mockResolverSucceed + + lbs, err := lbInit() + if err != nil { + t.Errorf("lbInit returned an unexpected error: '%v'", err) + } + for _, l := range lbs { + if err := l.start(); err != nil { + t.Errorf("lb start returned an unexpected error: '%v'", err) + } + } + + t.Logf("waiting %ds for LB to boot", wt) + time.Sleep(time.Duration(wt) * time.Second) + + // test returned 0 TTL + defaultDnsResolver = mockResolver0TTL + t.Logf("waiting %ds for DNS check and upstream update to complete", wt) + time.Sleep(time.Duration(wt) * time.Second) + + lbs[0].stop() + + // Now test with 0 TTL at start + defaultDnsResolver = mockResolver0TTL + + lbs, err = lbInit() + if err != nil { + t.Errorf("lbInit returned an unexpected error: '%v'", err) + } + for _, l := range lbs { + if err := l.start(); err != nil { + t.Errorf("lb start returned an unexpected error: '%v'", err) + } + } + os.Remove(testConfigPath) + + t.Logf("waiting %ds for LB to boot", wt) + time.Sleep(time.Duration(wt) * time.Second) + + lbs[0].stop() + defaultDnsResolver = miekgResolver +} diff --git a/lb_test.go b/lb_test.go new file mode 100644 index 0000000..bef34cf --- /dev/null +++ b/lb_test.go @@ -0,0 +1,1117 @@ +package main + +import ( + "errors" + "net" + "os" + "reflect" + "strings" + "sync" + "testing" + "time" + + "gopkg.in/yaml.v3" +) + +var testConfigYaml = `lb: + - engine: nftables # load balancer engine. only 'nftables' supported for now + targets: + - name: target1 # target name + protocol: tcp # transport protocol. only tcp supported for now + port: 8080 # target port + upstream_group: + name: ug0 # upstream_group name + distribution: round-robin # ug traffic distribution mode. only round-robin supported for now + upstreams: + - name: t1ug0u1 # upstream name + host: 8.8.8.8 # upstream host. IP Address or domain name + port: 8080 # upstream port + health_check: # don't include the health_check mapping or leave it empty to disable health_check. upstreams will be considered always as active when health_checks are not enabled + - name: t1ug0u2 # upstream name + host: 8.8.8.8 # upstream host. IP Address or domain name + port: 6443 # upstream port + health_check: # don't include the health_check mapping or leave it empty to disable health_check. upstreams will be considered always as active when health_checks are not enabled + protocol: tcp # health-heck protocol. only tcp supported for now + port: 6443 # health_check port + start_available: true # set 'true' if upstream should be considered as available at start. set 'false' otherwise + probe: + check_interval: 10 # seconds. Max value: 65536 + timeout: 2 # seconds. Max value: 256 + success_count: 3 # amount of successful health checks to become active + - name: t1ug0u3 # upstream name + host: 8.8.8.8 # upstream host. IP Address or domain name + port: 8081 # upstream port + health_check: # don't include the health_check mapping or leave it empty to disable health_check. upstreams will be considered always as active when health_checks are not enabled + protocol: tcp # health-heck protocol. only tcp supported for now + port: 8081 # health_check port + start_available: false # set 'true' if upstream should be considered as available at start. set 'false' otherwise + probe: + check_interval: 10 # seconds. Max value: 65536 + timeout: 2 # seconds. Max value: 256 + success_count: 3 # amount of successful health checks to become active + - name: target2 # target name + protocol: tcp # transport protocol. only tcp supported for now + port: 8081 # target port + upstream_group: # upstream_group to be used for target + name: ug1 # upstream_group name + distribution: round-robin # ug traffic distribution mode. only round-robin supported for now + upstreams: + - name: t2ug1u1 # upstream name + host: 8.8.8.8 # upstream host. IP Address or domain name + port: 8082 # upstream port + health_check: # don't include the health_check mapping or leave it empty to disable health_check. upstreams will be considered always as active when health_checks are not enabled + protocol: tcp # health-heck protocol. only tcp supported for now + port: 8082 # health_check port + start_available: true # set 'true' if upstream should be considered as available at start. set 'false' otherwise + probe: + check_interval: 10 # seconds. Max value: 65536 + timeout: 2 # seconds. Max value: 256 + success_count: 3 # amount of successful health checks to become active + - name: t2ug1u2 # upstream name + host: lobby-test.ipbuff.com.fail # upstream host. IP Address or domain name + port: 8081 # upstream port + health_check: # don't include the health_check mapping or leave it empty to disable health_check. upstreams will be considered always as active when health_checks are not enabled + protocol: tcp # health-heck protocol. only tcp supported for now + port: 8081 # health_check port + start_available: false # set 'true' if upstream should be considered as available at start. set 'false' otherwise + probe: + check_interval: 10 # seconds. Max value: 65536 + timeout: 2 # seconds. Max value: 256 + success_count: 3 # amount of successful health checks to become active + - name: target3 # target name + protocol: tcp # transport protocol. only tcp supported for now + port: 8082 # target port + upstream_group: # upstream_group to be used for target + name: ug2 # upstream_group name + distribution: round-robin # ug traffic distribution mode. only round-robin supported for now + upstreams: + - name: t3ug2u1 # upstream name + host: lobby-test.ipbuff.com # upstream host. IP Address or domain name + port: 8082 # upstream port + dns: # include in case you want to use specific DNS to resolve the fqdn host address. If host is IPv4 or IPv6 this setting will not have any effect. In case this mapping is not present the OS resolvers will be used + servers: # dns address list. Queries will be done sequentially in case of failure + - 1.1.1.1 # cloudflare IPv4 DNS + - 2606:4700::1111 # cloudflare IPv6 DNS + ttl: 5 # custom ttl can be specified to overwrite the DNS response TTL + health_check: # don't include the health_check mapping or leave it empty to disable health_check. upstreams will be considered always as active when health_checks are not enabled + protocol: tcp # health-heck protocol. only tcp supported for now + port: 8082 # health_check port + start_available: true # set 'true' if upstream should be considered as available at start. set 'false' otherwise + probe: + check_interval: 10 # seconds. Max value: 65536 + timeout: 2 # seconds. Max value: 256 + success_count: 3 # amount of successful health checks to become active + - name: t3ug2u2 # upstream name + host: lobby-test.ipbuff.com # upstream host. IP Address or domain name + port: 8083 # upstream port + dns: # include in case you want to use specific DNS to resolve the fqdn host address. If host is IPv4 or IPv6 this setting will not have any effect. In case this mapping is not present the OS resolvers will be used + servers: # dns address list. Queries will be done sequentially in case of failure + - 8.8.8.8 # google IPv4 DNS + - 1.1.1.1 # cloudflare IPv4 DNS + - 2606:4700::1111 # cloudflare IPv6 DNS + health_check: # don't include the health_check mapping or leave it empty to disable health_check. upstreams will be considered always as active when health_checks are not enabled + protocol: tcp # health-heck protocol. only tcp supported for now + port: 8083 # health_check port + start_available: false # set 'true' if upstream should be considered as available at start. set 'false' otherwise + probe: + check_interval: 10 # seconds. Max value: 65536 + timeout: 2 # seconds. Max value: 256 + success_count: 3 # amount of successful health checks to become active +` + +func TestGetDistMode(t *testing.T) { + testCases := []struct { + input string + err error + result distMode + }{ + {input: "round-robin", err: nil, result: distModeRR}, + {input: "weighted", err: nil, result: distModeWeighted}, + {input: "blah", err: errDistMode, result: distModeUnknown}, + } + + for _, tc := range testCases { + t.Run(tc.input, func(t *testing.T) { + dm, err := getDistMode(tc.input) + if !errors.Is(err, tc.err) { + t.Errorf("%s: expected '%v', but got '%v'", tc.input, tc.err, err) + } + if dm != tc.result { + t.Errorf("%s: expected '%v', but got '%v'", tc.input, tc.result, dm) + } + }) + } +} + +func TestDistModeString(t *testing.T) { + testCases := []struct { + input distMode + result string + }{ + {input: distModeRR, result: "round-robin"}, + {input: distModeWeighted, result: "weighted"}, + {input: distModeUnknown, result: "unknown"}, + {input: 9, result: "unknown"}, + } + + for _, tc := range testCases { + t.Run(tc.result, func(t *testing.T) { + r := tc.input.String() + if r != tc.result { + t.Errorf("%s: expected '%v', but got '%v'", tc.result, tc.result, r) + } + }) + } +} + +func TestGetLbEngineType(t *testing.T) { + testCases := []struct { + input string + err error + result lbEngineType + }{ + {input: "testEngine", err: nil, result: lbEngineTest}, + {input: "nftables", err: nil, result: lbEngineNft}, + {input: "blah", err: errLbEngineType, result: lbEngineUnknown}, + } + + for _, tc := range testCases { + t.Run(tc.input, func(t *testing.T) { + r, err := getLbEngineType(tc.input) + if !errors.Is(err, tc.err) { + t.Errorf("%s: expected '%v', but got '%v'", tc.input, tc.err, err) + } + if r != tc.result { + t.Errorf("%s: expected '%v', but got '%v'", tc.input, tc.result, r) + } + }) + } +} + +func TestNewLbEngine(t *testing.T) { + testCases := []struct { + input lbEngineType + err error + result lbEngine + }{ + {input: lbEngineNft, err: nil, result: &nft{}}, + {input: lbEngineUnknown, err: errLbEngineType, result: nil}, + } + + for _, tc := range testCases { + t.Run(tc.input.String(), func(t *testing.T) { + r, err := newLbEngine(tc.input) + if !errors.Is(err, tc.err) { + t.Errorf("%s: expected '%v', but got '%v'", tc.input.String(), tc.err, err) + } + if reflect.TypeOf(r) != reflect.TypeOf(tc.result) { + t.Errorf( + "%s: expected '%v', but got '%v'", + tc.input.String(), + reflect.TypeOf(tc.result), + reflect.TypeOf(r), + ) + } + }) + } +} + +func TestLbEngineTypeString(t *testing.T) { + testCases := []struct { + input lbEngineType + result string + }{ + {input: lbEngineNft, result: "nftables"}, + {input: lbEngineUnknown, result: "unknown"}, + {input: 9, result: "unknown"}, + } + + for _, tc := range testCases { + t.Run(tc.result, func(t *testing.T) { + r := tc.input.String() + if r != tc.result { + t.Errorf("%s: expected '%v', but got '%v'", tc.result, tc.result, r) + } + }) + } +} + +func TestGetLbProtocol(t *testing.T) { + testCases := []struct { + input string + err error + result lbProto + }{ + {input: "tcp", err: nil, result: lbProtoTcp}, + {input: "udp", err: nil, result: lbProtoUdp}, + {input: "sctp", err: nil, result: lbProtoSctp}, + {input: "http", err: nil, result: lbProtoHttp}, + {input: "blah", err: errLbProto, result: lbProtoUnknown}, + } + + for _, tc := range testCases { + t.Run(tc.input, func(t *testing.T) { + r, err := getLbProtocol(tc.input) + if !errors.Is(err, tc.err) { + t.Errorf("%s: expected '%v', but got '%v'", tc.input, tc.err, err) + } + if r != tc.result { + t.Errorf("%s: expected '%v', but got '%v'", tc.input, tc.result, r) + } + }) + } +} + +func TestLbProtoString(t *testing.T) { + testCases := []struct { + input lbProto + result string + }{ + {input: lbProtoTcp, result: "tcp"}, + {input: lbProtoUdp, result: "udp"}, + {input: lbProtoSctp, result: "sctp"}, + {input: 9, result: "unknown"}, + } + + for _, tc := range testCases { + t.Run(tc.result, func(t *testing.T) { + r := tc.input.String() + if r != tc.result { + t.Errorf("%s: expected '%v', but got '%v'", tc.result, tc.result, r) + } + }) + } +} + +func TestAddAndReplaceUpstreamIps(t *testing.T) { + ips := []string{"1.1.1.1", "2.2.2.2"} + netIps := &[]net.IP{} + l := &lb{} + l.upstreamIps = &[]net.IP{} + + for _, i := range ips { + ip := net.ParseIP(i) + l.addUpstreamIps(ip) + *netIps = append(*netIps, ip) + } + + if !reflect.DeepEqual(netIps, l.upstreamIps) { + t.Errorf("expected '%v', but got '%v'", netIps, l.upstreamIps) + } + + l.addUpstreamIps(nil) + + rip := net.ParseIP("3.3.3.3") + l.replaceUpstreamIps(&(*netIps)[0], &rip) + (*netIps)[0] = rip + + if !reflect.DeepEqual(netIps, l.upstreamIps) { + t.Errorf("expected '%v', but got '%v'", netIps, l.upstreamIps) + } + + uip := net.ParseIP("4.4.4.4") + err := l.replaceUpstreamIps(&uip, &rip) + if err == nil { + t.Errorf("expected error, but didn't get it") + } else { + if !errors.Is(err, errReplUpstreamIp) { + t.Errorf("expected '%v', but got '%v'", errReplUpstreamIp, err) + } + } +} + +func TestCheckConfig(t *testing.T) { + config := testConfigYaml + configYaml := ConfigYaml{} + + if err := yaml.Unmarshal([]byte(config), &configYaml); err != nil { + t.Error("errored on config yaml parsing", err) + } + + // confirm checkConfig succeeds + if err := checkConfig(&configYaml); err != nil { + t.Error("checkConfig errored unexpectedly", err) + } + + // confirm checkConfig fails on wrong engine configuration + expectedErr := errLbEngineType + configYaml.LbConfig[0].Engine = "blah" + if err := checkConfig(&configYaml); !(errors.Is(err, errLbCheckConf) && + errors.Is(err, expectedErr)) { + t.Errorf( + "checkConfig should have errored with '%v: %v', but errored with '%v'", + errLbCheckConf, + expectedErr, + err, + ) + } + + // confirm checkConfig fails on repeated engine configuration + wrongConfigSlice := strings.Split(config, "\n") + wrongConfig := config + strings.Join(wrongConfigSlice[1:], "\n") + expectedErr = errConfRepEngine + if err := yaml.Unmarshal([]byte(wrongConfig), &configYaml); err != nil { + t.Error("errored on config yaml parsing", err) + } + if err := checkConfig(&configYaml); !(errors.Is(err, errLbCheckConf) && + errors.Is(err, expectedErr)) { + t.Errorf( + "checkConfig should have errored with '%v: %v', but errored with '%v'", + errLbCheckConf, + expectedErr, + err, + ) + } + + // confirm checkConfig fails on repeated target name + wrongConfig = config + wrongConfig = strings.ReplaceAll(wrongConfig, "target2", "target1") + expectedErr = errConfRepTargetName + if err := yaml.Unmarshal([]byte(wrongConfig), &configYaml); err != nil { + t.Error("errored on config yaml parsing", err) + } + if err := checkConfig(&configYaml); !(errors.Is(err, errLbCheckConf) && + errors.Is(err, expectedErr)) { + t.Errorf( + "checkConfig should have errored with '%v: %v', but errored with '%v'", + errLbCheckConf, + expectedErr, + err, + ) + } + + // confirm checkConfig fails on repeated target protocol and port + wrongConfig = config + wrongConfig = strings.ReplaceAll( + wrongConfig, + "port: 8081 # target port", + "port: 8080 # target port", + ) + expectedErr = errConfRepPortProto + if err := yaml.Unmarshal([]byte(wrongConfig), &configYaml); err != nil { + t.Error("errored on config yaml parsing", err) + } + if err := checkConfig(&configYaml); !(errors.Is(err, errLbCheckConf) && + errors.Is(err, expectedErr)) { + t.Errorf( + "checkConfig should have errored with '%v: %v', but errored with '%v'", + errLbCheckConf, + expectedErr, + err, + ) + } + + // confirm checkConfig fails on repeated upstreamGroup name + wrongConfig = config + wrongConfig = strings.ReplaceAll( + wrongConfig, + "ug1", + "ug0", + ) + expectedErr = errConfRepUGName + if err := yaml.Unmarshal([]byte(wrongConfig), &configYaml); err != nil { + t.Error("errored on config yaml parsing", err) + } + if err := checkConfig(&configYaml); !(errors.Is(err, errLbCheckConf) && + errors.Is(err, expectedErr)) { + t.Errorf( + "checkConfig should have errored with '%v: %v', but errored with '%v'", + errLbCheckConf, + expectedErr, + err, + ) + } + + // confirm checkConfig fails on invalid target protocol + wrongConfig = config + wrongConfig = strings.ReplaceAll( + wrongConfig, + " protocol: tcp", + " protocol: blah", + ) + expectedErr = errLbProto + if err := yaml.Unmarshal([]byte(wrongConfig), &configYaml); err != nil { + t.Error("errored on config yaml parsing", err) + } + if err := checkConfig(&configYaml); !(errors.Is(err, errLbCheckConf) && + errors.Is(err, expectedErr)) { + t.Errorf( + "checkConfig should have errored with '%v: %v', but errored with '%v'", + errLbCheckConf, + expectedErr, + err, + ) + } + + // confirm checkConfig fails on unsupported target protocol + wrongConfig = config + wrongConfig = strings.ReplaceAll( + wrongConfig, + " protocol: tcp", + " protocol: http", + ) + expectedErr = errConfTargetProto + if err := yaml.Unmarshal([]byte(wrongConfig), &configYaml); err != nil { + t.Error("errored on config yaml parsing", err) + } + if err := checkConfig(&configYaml); !(errors.Is(err, errLbCheckConf) && + errors.Is(err, expectedErr)) { + t.Errorf( + "checkConfig should have errored with '%v: %v', but errored with '%v'", + errLbCheckConf, + expectedErr, + err, + ) + } + + // confirm checkConfig fails on invalid healtcheck protocol + wrongConfig = config + wrongConfig = strings.ReplaceAll( + wrongConfig, + " protocol: tcp # health-heck protocol. only tcp supported for now", + " protocol: blah", + ) + expectedErr = errConfHcProtocol + if err := yaml.Unmarshal([]byte(wrongConfig), &configYaml); err != nil { + t.Error("errored on config yaml parsing", err) + } + if err := checkConfig(&configYaml); !(errors.Is(err, errLbCheckConf) && + errors.Is(err, expectedErr)) { + t.Errorf( + "checkConfig should have errored with '%v: %v', but errored with '%v'", + errLbCheckConf, + expectedErr, + err, + ) + } + + // confirm checkConfig fails on wrong distribution mode + wrongConfig = config + wrongConfig = strings.ReplaceAll( + wrongConfig, + " distribution: round-robin", + " distribution: bleh", + ) + expectedErr = errConfDistMode + if err := yaml.Unmarshal([]byte(wrongConfig), &configYaml); err != nil { + t.Error("errored on config yaml parsing", err) + } + if err := checkConfig(&configYaml); !(errors.Is(err, errLbCheckConf) && + errors.Is(err, expectedErr)) { + t.Errorf( + "checkConfig should have errored with '%v: %v', but errored with '%v'", + errLbCheckConf, + expectedErr, + err, + ) + } + + // confirm checkConfig fails on repeated upstream name + wrongConfig = config + wrongConfig = strings.ReplaceAll( + wrongConfig, + "t1ug0u2", + "t1ug0u1", + ) + expectedErr = errConfRepUName + if err := yaml.Unmarshal([]byte(wrongConfig), &configYaml); err != nil { + t.Error("errored on config yaml parsing", err) + } + if err := checkConfig(&configYaml); !(errors.Is(err, errLbCheckConf) && + errors.Is(err, expectedErr)) { + t.Errorf( + "checkConfig should have errored with '%v: %v', but errored with '%v'", + errLbCheckConf, + expectedErr, + err, + ) + } + + // confirm checkConfig fails on invalid upstream host + wrongConfig = config + wrongConfig = strings.ReplaceAll( + wrongConfig, + "8.8.8.8", + "8.8.8.8.8", + ) + expectedErr = errConfUHost + if err := yaml.Unmarshal([]byte(wrongConfig), &configYaml); err != nil { + t.Error("errored on config yaml parsing", err) + } + if err := checkConfig(&configYaml); !(errors.Is(err, errLbCheckConf) && + errors.Is(err, expectedErr)) { + t.Errorf( + "checkConfig should have errored with '%v: %v', but errored with '%v'", + errLbCheckConf, + expectedErr, + err, + ) + } + + // confirm checkConfig fails on invalid upstream dns address + wrongConfig = config + wrongConfig = strings.ReplaceAll( + wrongConfig, + "1.1.1.1", + "1.1.1.1.1", + ) + expectedErr = errConfDnsAddr + if err := yaml.Unmarshal([]byte(wrongConfig), &configYaml); err != nil { + t.Error("errored on config yaml parsing", err) + } + if err := checkConfig(&configYaml); !(errors.Is(err, errLbCheckConf) && + errors.Is(err, expectedErr)) { + t.Errorf( + "checkConfig should have errored with '%v: %v', but errored with '%v'", + errLbCheckConf, + expectedErr, + err, + ) + } +} + +func TestGetConfig(t *testing.T) { + config := testConfigYaml + configYaml := ConfigYaml{} + + if err := yaml.Unmarshal([]byte(config), &configYaml); err != nil { + t.Error("errored on config yaml parsing", err) + } + + // confirm checkConfig succeeds + if err := checkConfig(&configYaml); err != nil { + t.Error("checkConfig errored unexpectedly", err) + } + + lbc := configYaml.LbConfig[0] + + // confirm getConfig succeeds with resolved fqdn + l := &lb{} + l.upstreamIps = &[]net.IP{} + if err := l.getConfig(&lbc); err != nil { + t.Errorf("getConfig errored unexpectedly: '%v'", err) + } + + // confirm getConfig succeeds with unresolved fqdn and dns ttl configured + bkpHost := lbc.TargetsConfig[2].UpstreamGroup.Upstreams[0].Host + lbc.TargetsConfig[2].UpstreamGroup.Upstreams[0].Host = "unresolved.dns" + l = &lb{} + l.upstreamIps = &[]net.IP{} + if err := l.getConfig(&lbc); err != nil { + t.Errorf("getConfig errored unexpectedly: '%v'", err) + } + lbc.TargetsConfig[2].UpstreamGroup.Upstreams[0].Host = bkpHost + + // confirm getConfig succeeds with unresolved fqdn and dns ttl configured + bkpHost = lbc.TargetsConfig[2].UpstreamGroup.Upstreams[1].Host + lbc.TargetsConfig[2].UpstreamGroup.Upstreams[1].Host = "unresolved.dns" + l = &lb{} + l.upstreamIps = &[]net.IP{} + if err := l.getConfig(&lbc); err != nil { + t.Errorf("getConfig errored unexpectedly: '%v'", err) + } + lbc.TargetsConfig[2].UpstreamGroup.Upstreams[1].Host = bkpHost + + // confirm getConfig succeeds with invalid host + lbc.TargetsConfig[2].UpstreamGroup.Upstreams[0].Host = "1.1.1.1.1" + l = &lb{} + l.upstreamIps = &[]net.IP{} + if err := l.getConfig(&lbc); err != nil { + t.Errorf("getConfig errored unexpectedly: '%v'", err) + } + lbc.TargetsConfig[2].UpstreamGroup.Upstreams[0].Host = "1.1.1.1.1" + lbc.TargetsConfig[2].UpstreamGroup.Upstreams[0].Host = bkpHost + + // fail on engine type + bkpEngine := lbc.Engine + lbc.Engine = "blah" + l = &lb{} + l.upstreamIps = &[]net.IP{} + if err := l.getConfig(&lbc); err == nil { + t.Errorf("expected an error to occur, but got no error") + } else { + if !errors.Is(err, errLbEngineType) { + t.Errorf("expected error '%v', but got '%v'", errLbEngineType, err) + } + } + lbc.Engine = bkpEngine + + // fail on distribution mode + bkpDM := lbc.TargetsConfig[0].UpstreamGroup.Distribution + lbc.TargetsConfig[0].UpstreamGroup.Distribution = "blah" + l = &lb{} + l.upstreamIps = &[]net.IP{} + if err := l.getConfig(&lbc); err == nil { + t.Errorf("expected an error to occur, but got no error") + } else { + if !errors.Is(err, errDistMode) { + t.Errorf("expected error '%v', but got '%v'", errDistMode, err) + } + } + lbc.TargetsConfig[0].UpstreamGroup.Distribution = bkpDM +} + +func TestLbSCompare(t *testing.T) { + olb := &lb{ + et: lbEngineNft, + } + nlb := &lb{ + et: lbEngineUnknown, + } + olbs := []*lb{ + olb, + } + nlbs := []*lb{ + olb, + nlb, + } + + expected := struct { + kept *map[lbEngineType]*lb + niuew []*lb + old []*lb + }{} + + kept := make(map[lbEngineType]*lb) + kept[lbEngineNft] = olb + expected.kept = &kept + + k, a, _ := lbsCompare(olbs, nlbs) + + // check kept + kr := (*k)[lbEngineNft] + if kr[0].et != (*expected.kept)[lbEngineNft].et { + t.Errorf( + "outcome didn't match for the kept lbs. expected: '%v'\nbut got '%v'", + kr[0], + (*expected.kept)[lbEngineNft], + ) + } + + // check added + if a[0].et != nlb.et { + t.Errorf( + "outcome didn't match for the added lbs. expected: '%v'\nbut got '%v'", + nlb, + a[0], + ) + } + + _, _, r := lbsCompare(nlbs, olbs) + + // check removed + if r[0].et != nlb.et { + t.Errorf( + "outcome didn't match for the removed lbs. expected: '%v'\nbut got '%v'", + nlb, + r[0], + ) + } +} + +func TestChecks(t *testing.T) { + config := testConfigYaml + configYaml := ConfigYaml{} + + if err := yaml.Unmarshal([]byte(config), &configYaml); err != nil { + t.Error("errored on config yaml parsing", err) + } + + // confirm checkConfig succeeds + if err := checkConfig(&configYaml); err != nil { + t.Error("checkConfig errored unexpectedly", err) + } + + lbc := configYaml.LbConfig[0] + + l := &lb{} + l.upstreamIps = &[]net.IP{} + l.state.wg = &sync.WaitGroup{} + if err := l.getConfig(&lbc); err != nil { + t.Errorf("getConfig errored unexpectedly: '%v'", err) + } + + l.startChecks() + l.stopChecks() +} + +func TestCheckStop(t *testing.T) { + config := testConfigYaml + configYaml := ConfigYaml{} + + if err := yaml.Unmarshal([]byte(config), &configYaml); err != nil { + t.Error("errored on config yaml parsing", err) + } + + // confirm checkConfig succeeds + if err := checkConfig(&configYaml); err != nil { + t.Error("checkConfig errored unexpectedly", err) + } + + lbc := configYaml.LbConfig[0] + + l := &lb{} + l.upstreamIps = &[]net.IP{} + l.state.wg = &sync.WaitGroup{} + if err := l.getConfig(&lbc); err != nil { + t.Errorf("getConfig errored unexpectedly: '%v'", err) + } + + l.startChecks() + l.stopDcs() + l.stopHcs() + l.state.wg.Wait() +} + +func TestInitDnsCheck(t *testing.T) { + t.Log("Testing Init DNS Check") + config := `lb: + - engine: testEngine # load balancer engine. only 'nftables' supported for now + targets: + - name: TestInitDnsCheck # target name + protocol: tcp # transport protocol. only tcp supported for now + port: 8082 # target port + upstream_group: # upstream_group to be used for target + name: ug # upstream_group name + distribution: round-robin # ug traffic distribution mode. only round-robin supported for now + upstreams: + - name: lt8082 # upstream name + host: lobby-test.ipbuff.com # upstream host. IP Address or domain name + port: 8082 # upstream port + dns: # include in case you want to use specific DNS to resolve the fqdn host address. If host is IPv4 or IPv6 this setting will not have any effect. In case this mapping is not present the OS resolvers will be used + servers: # dns address list. Queries will be done sequentially in case of failure + - 1.1.1.1 # cloudflare IPv4 DNS + - 2606:4700::1111 # cloudflare IPv6 DNS + ttl: 1 # custom ttl can be specified to overwrite the DNS response TTL +` + + configYaml := ConfigYaml{} + + if err := yaml.Unmarshal([]byte(config), &configYaml); err != nil { + t.Error("errored on config yaml parsing", err) + } + + // confirm checkConfig succeeds + if err := checkConfig(&configYaml); err != nil { + t.Error("checkConfig errored unexpectedly", err) + } + + lbc := configYaml.LbConfig[0] + + l := &lb{} + l.upstreamIps = &[]net.IP{} + l.state.wg = &sync.WaitGroup{} + if err := l.getConfig(&lbc); err != nil { + t.Errorf("getConfig errored unexpectedly: '%v'", err) + } + + var err error + if l.e, err = newLbEngine(l.et); err != nil { + t.Error("%w: %w", errLbInit, err) + } + + l.initDnsCheck(l.targets[0].upstreamGroup.upstreams[0]) + + wt := 2 + t.Logf("waiting %ds for DNS check to complete", wt) + time.Sleep(time.Duration(wt) * time.Second) + + // test terminate state + l.state.m.Lock() + l.state.t = true + l.state.m.Unlock() + time.Sleep(time.Duration(wt) * time.Second) + l.stopChecks() +} + +func TestInitHCheck(t *testing.T) { + t.Log("Testing Init Health Check") + config := `lb: + - engine: testEngine # load balancer engine. only 'nftables' supported for now + targets: + - name: TestInitHealthCheck # target name + protocol: tcp # transport protocol. only tcp supported for now + port: 8082 # target port + upstream_group: # upstream_group to be used for target + name: ug # upstream_group name + distribution: round-robin # ug traffic distribution mode. only round-robin supported for now + upstreams: + - name: lt8082 # upstream name + host: lobby-test.ipbuff.com # upstream host. IP Address or domain name + port: 8082 # upstream port + dns: # include in case you want to use specific DNS to resolve the fqdn host address. If host is IPv4 or IPv6 this setting will not have any effect. In case this mapping is not present the OS resolvers will be used + servers: # dns address list. Queries will be done sequentially in case of failure + - 1.1.1.1 # cloudflare IPv4 DNS + - 2606:4700::1111 # cloudflare IPv6 DNS + ttl: 1 # custom ttl can be specified to overwrite the DNS response TTL + health_check: # don't include the health_check mapping or leave it empty to disable health_check. upstreams will be considered always as active when health_checks are not enabled + protocol: tcp # health-heck protocol. only tcp supported for now + port: 8082 # health_check port + start_available: true # set 'true' if upstream should be considered as available at start. set 'false' otherwise + probe: + check_interval: 1 # seconds. Max value: 65536 + timeout: 2 # seconds. Max value: 256 + success_count: 3 # amount of successful health checks to become active +` + + configYaml := ConfigYaml{} + + if err := yaml.Unmarshal([]byte(config), &configYaml); err != nil { + t.Error("errored on config yaml parsing", err) + } + + // confirm checkConfig succeeds + if err := checkConfig(&configYaml); err != nil { + t.Error("checkConfig errored unexpectedly", err) + } + + lbc := configYaml.LbConfig[0] + + l := &lb{} + l.upstreamIps = &[]net.IP{} + l.state.wg = &sync.WaitGroup{} + if err := l.getConfig(&lbc); err != nil { + t.Errorf("getConfig errored unexpectedly: '%v'", err) + } + + var err error + if l.e, err = newLbEngine(l.et); err != nil { + t.Error("%w: %w", errLbInit, err) + } + + l.initHealthCheck(l.targets[0].upstreamGroup.upstreams[0], l.targets[0]) + + wt := 3 + t.Logf("waiting %ds for Healthcheck to complete", wt) + time.Sleep(time.Duration(wt) * time.Second) + + // test terminate state + l.state.m.Lock() + l.state.t = true + l.state.m.Unlock() + time.Sleep(time.Duration(wt) * time.Second) + l.stopChecks() +} + +func TestLb(t *testing.T) { + config := `lb: + - engine: testEngine # load balancer engine. only 'nftables' supported for now + targets: + - name: TestInitDnsCheck # target name + protocol: tcp # transport protocol. only tcp supported for now + port: 8082 # target port + upstream_group: # upstream_group to be used for target + name: ug # upstream_group name + distribution: round-robin # ug traffic distribution mode. only round-robin supported for now + upstreams: + - name: lt8082 # upstream name + host: lobby-test.ipbuff.com # upstream host. IP Address or domain name + port: 8082 # upstream port + dns: # include in case you want to use specific DNS to resolve the fqdn host address. If host is IPv4 or IPv6 this setting will not have any effect. In case this mapping is not present the OS resolvers will be used + servers: # dns address list. Queries will be done sequentially in case of failure + - 1.1.1.1 # cloudflare IPv4 DNS + - 2606:4700::1111 # cloudflare IPv6 DNS + ttl: 1 # custom ttl can be specified to overwrite the DNS response TTL + health_check: # don't include the health_check mapping or leave it empty to disable health_check. upstreams will be considered always as active when health_checks are not enabled + protocol: tcp # health-heck protocol. only tcp supported for now + port: 8082 # health_check port + start_available: true # set 'true' if upstream should be considered as available at start. set 'false' otherwise + probe: + check_interval: 1 # seconds. Max value: 65536 + timeout: 2 # seconds. Max value: 256 + success_count: 3 # amount of successful health checks to become active +` + + // test LB init fail due to config file not found + failedTestConfigPath := "/tmp/lobby_test_nil" + testConfigPath := "/tmp/lobby_test_conf.yaml" + + lobbySettings.configFilePath = failedTestConfigPath + + _, err := lbInit() + if err == nil { + t.Errorf("lbInit should have errored due to wrong config file path") + } else { + if !errors.Is(err, errConfFileOpen) { + t.Errorf("expected error '%v', but got '%v'", errConfFileOpen, err) + } + } + + lobbySettings.configFilePath = testConfigPath + + // test LB init fail due to invalid yaml format + failedConfig := config + failedConfig = strings.Replace(failedConfig, "lb:", "lb", 1) + os.Remove(testConfigPath) + os.WriteFile(testConfigPath, []byte(failedConfig), 0400) + + _, err = lbInit() + if err == nil { + confFile, _ := os.ReadFile(testConfigPath) + t.Log("failedConfigFile:\n", string(confFile)) + t.Errorf("lbInit should have errored due to invalid yaml") + } else { + if !errors.Is(err, errConfFileUnmarshal) { + t.Errorf("expected error '%v', but got '%v'", errConfFileUnmarshal, err) + } + } + os.Remove(testConfigPath) + + // test LB init fail due to unsupported engine + failedConfig = config + failedConfig = strings.Replace(failedConfig, "engine: testEngine", "engine: blah", 1) + os.WriteFile(testConfigPath, []byte(failedConfig), 0400) + + _, err = lbInit() + if err == nil { + t.Errorf("lbInit should have errored due to wrong configuration") + } else { + if !errors.Is(err, errLbInit) { + t.Errorf("expected error '%v', but got '%v'", errLbInit, err) + } + } + os.Remove(testConfigPath) + + // test LB init fail due to misconfigured probe: timeout + failedConfig = config + failedConfig = strings.Replace(failedConfig, "timeout: 2", "timeout: 0", 1) + os.WriteFile(testConfigPath, []byte(failedConfig), 0400) + + _, err = lbInit() + if err == nil { + t.Errorf("lbInit should have errored due to wrong configuration") + } else { + if !errors.Is(err, errLbInit) { + t.Errorf("expected error '%v', but got '%v'", errLbInit, err) + } + } + os.Remove(testConfigPath) + + // test LB init fail due to misconfigured probe: check_interval + failedConfig = config + failedConfig = strings.Replace(failedConfig, "check_interval: 1", "check_interval: 0", 1) + os.WriteFile(testConfigPath, []byte(failedConfig), 0400) + + _, err = lbInit() + if err == nil { + t.Log("Config: \n", failedConfig) + t.Errorf("lbInit should have errored due to wrong configuration") + } else { + if !errors.Is(err, errLbInit) { + t.Errorf("expected error '%v', but got '%v'", errLbInit, err) + } + } + os.Remove(testConfigPath) + + // test LB init fail due to misconfigured probe: success_count + failedConfig = config + failedConfig = strings.Replace(failedConfig, "success_count: 3", "success_count: 0", 1) + os.WriteFile(testConfigPath, []byte(failedConfig), 0400) + + _, err = lbInit() + if err == nil { + t.Errorf("lbInit should have errored due to wrong configuration") + } else { + if !errors.Is(err, errLbInit) { + t.Errorf("expected error '%v', but got '%v'", errLbInit, err) + } + } + os.Remove(testConfigPath) + + // test LB start fail scenarios + os.WriteFile(testConfigPath, []byte(config), 0400) + lbs, err := lbInit() + if err != nil { + t.Errorf("lbInit returned an unexpected error: '%v'", err) + } + for _, l := range lbs { + e := l.e.(*testLb) + + e.setResults(false, true, false) + if err := l.start(); err == nil { + t.Errorf("lb start should have errored due to failed permissions") + } else { + if !errors.Is(err, errCheckPerm) { + t.Errorf("expected error '%v', but got '%v'", errCheckPerm, err) + } + } + + e.setResults(true, false, false) + if err := l.start(); err == nil { + t.Errorf("lb start should have errored due to failed dependencies") + } else { + if !errors.Is(err, errCheckDep) { + t.Errorf("expected error '%v', but got '%v'", errCheckDep, err) + } + } + + e.setResults(false, false, true) + if err := l.start(); err == nil { + t.Errorf("lb start should have errored due to failed start") + } else { + if !errors.Is(err, errLbEngineStart) { + t.Errorf("expected error '%v', but got '%v'", errLbEngineStart, err) + } + } + } + os.Remove(testConfigPath) + + // test successful LB start + os.WriteFile(testConfigPath, []byte(config), 0400) + lbs, err = lbInit() + if err != nil { + t.Errorf("lbInit returned an unexpected error: '%v'", err) + } + + for _, l := range lbs { + if err := l.start(); err != nil { + t.Errorf("lb start returned an unexpected error: '%v'", err) + } + if err := lbReconfig(&lbs); err != nil { + t.Errorf("lbReconfig returned an unexpected error: '%v'", err) + } + } + os.Remove(testConfigPath) + + // test failed lb update due to invalid yaml + updatedConfig := strings.Replace(config, "host: lobby-test.ipbuff.com", "host lobby-test.ipbuff.comb", 1) + os.WriteFile(testConfigPath, []byte(updatedConfig), 0400) + if err := lbReconfig(&lbs); err == nil { + t.Errorf("lb start should have errored due to failed start") + } else { + if !errors.Is(err, errLbInit) { + t.Errorf("expected error '%v', but got '%v'", errLbInit, err) + } + } + os.Remove(testConfigPath) + + // test failed lb update due to invalid engine + updatedConfig = strings.Replace(config, "engine: testEngine", "engine: blah", 1) + os.WriteFile(testConfigPath, []byte(updatedConfig), 0400) + if err := lbReconfig(&lbs); err == nil { + t.Errorf("lb start should have errored due to failed start") + } else { + if !errors.Is(err, errLbEngineType) { + t.Errorf("expected error '%v', but got '%v'", errLbEngineType, err) + } + } + os.Remove(testConfigPath) + + // test update config by removing an lb engine + updatedConfig = `lb: +` + os.WriteFile(testConfigPath, []byte(updatedConfig), 0400) + if err := lbReconfig(&lbs); err != nil { + t.Errorf("lbInit returned an unexpected error: '%v'", err) + } + os.Remove(testConfigPath) + + // test update config by adding an lb engine + os.WriteFile(testConfigPath, []byte(config), 0400) + if err := lbReconfig(&lbs); err != nil { + t.Errorf("lbInit returned an unexpected error: '%v'", err) + } + os.Remove(testConfigPath) + + lbs[0].stop() +} diff --git a/logging.go b/logging.go new file mode 100644 index 0000000..bdabfe5 --- /dev/null +++ b/logging.go @@ -0,0 +1,88 @@ +package main + +import ( + "log" + "strings" +) + +type LogLevel byte + +const ( + logUnknown LogLevel = iota + logVerboseDebug + logDebug + logInfo + logWarning + logCritical +) + +// returns the string value of the LogLevel +func (ll LogLevel) String() string { + switch ll { + case logVerboseDebug: + return "VerboseDebug" + case logDebug: + return "Debug" + case logInfo: + return "Info" + case logWarning: + return "Warning" + case logCritical: + return "Critical" + } + return "Unknown" +} + +// return the LogLevel from string input +func getLogLevel(s string) LogLevel { + switch strings.ToLower(s) { + case "verbosedebug": + return logVerboseDebug + case "debug": + return logDebug + case "info": + return logInfo + case "warning": + return logWarning + case "critical": + return logCritical + } + return logUnknown +} + +type LogFunction func(string, ...interface{}) + +// LogDVf prints Verbose Debug logs +func LogDVf(format string, args ...interface{}) { + if lobbySettings.logLevel <= logVerboseDebug { + log.Printf("[DEBUG VERBOSE] "+format, args...) + } +} + +// LogDf prints Debug logs +func LogDf(format string, args ...interface{}) { + if lobbySettings.logLevel <= logDebug { + log.Printf("[DEBUG] "+format, args...) + } +} + +// LogIf prints Info logs +func LogIf(format string, args ...interface{}) { + if lobbySettings.logLevel <= logInfo { + log.Printf("[INFO] "+format, args...) + } +} + +// LogWf prints Warning logs +func LogWf(format string, args ...interface{}) { + if lobbySettings.logLevel <= logWarning { + log.Printf("[WARNING] "+format, args...) + } +} + +// LogCf prints Critical logs +func LogCf(format string, args ...interface{}) { + if lobbySettings.logLevel <= logCritical { + log.Printf("[CRITICAL] "+format, args...) + } +} diff --git a/logging_test.go b/logging_test.go new file mode 100644 index 0000000..6f20b61 --- /dev/null +++ b/logging_test.go @@ -0,0 +1,194 @@ +package main + +import ( + "bytes" + "fmt" + "log" + "os" + "strings" + "testing" +) + +var logLevels []LogLevel = []LogLevel{ + logUnknown, + logVerboseDebug, + logDebug, + logInfo, + logWarning, + logCritical, +} + +func TestLogDVf(t *testing.T) { + expectedPrint := map[LogLevel]bool{ + logUnknown: true, + logVerboseDebug: true, + logDebug: false, + logInfo: false, + logWarning: false, + logCritical: false, + } + + expectedString := "[DEBUG VERBOSE] " + + assertLogFuncs(t, expectedPrint, expectedString, LogDVf) + + if err := assertLogString(logVerboseDebug); err != nil { + t.Errorf("%v", err) + } +} + +func TestLogDf(t *testing.T) { + expectedPrint := map[LogLevel]bool{ + logUnknown: true, + logVerboseDebug: true, + logDebug: true, + logInfo: false, + logWarning: false, + logCritical: false, + } + + expectedString := "[DEBUG] " + + assertLogFuncs(t, expectedPrint, expectedString, LogDf) + + if err := assertLogString(logDebug); err != nil { + t.Errorf("%v", err) + } +} + +func TestLogIf(t *testing.T) { + expectedPrint := map[LogLevel]bool{ + logUnknown: true, + logVerboseDebug: true, + logDebug: true, + logInfo: true, + logWarning: false, + logCritical: false, + } + + expectedString := "[INFO] " + + assertLogFuncs(t, expectedPrint, expectedString, LogIf) + + if err := assertLogString(logInfo); err != nil { + t.Errorf("%v", err) + } +} + +func TestLogWf(t *testing.T) { + expectedPrint := map[LogLevel]bool{ + logUnknown: true, + logVerboseDebug: true, + logDebug: true, + logInfo: true, + logWarning: true, + logCritical: false, + } + + expectedString := "[WARNING] " + + assertLogFuncs(t, expectedPrint, expectedString, LogWf) + + if err := assertLogString(logWarning); err != nil { + t.Errorf("%v", err) + } +} + +func TestLogCf(t *testing.T) { + expectedPrint := map[LogLevel]bool{ + logUnknown: true, + logVerboseDebug: true, + logDebug: true, + logInfo: true, + logWarning: true, + logCritical: true, + } + + expectedString := "[CRITICAL] " + + assertLogFuncs(t, expectedPrint, expectedString, LogCf) + + if err := assertLogString(logCritical); err != nil { + t.Errorf("%v", err) + } +} + +func TestLogUnknown(t *testing.T) { + expectedPrint := map[LogLevel]bool{ + logUnknown: true, + logVerboseDebug: true, + logDebug: false, + logInfo: false, + logWarning: false, + logCritical: false, + } + + expectedString := "[DEBUG VERBOSE] " + + assertLogFuncs(t, expectedPrint, expectedString, LogDVf) + + if err := assertLogString(logUnknown); err != nil { + t.Errorf("%v", err) + } +} + +func assertLogFuncs( + t *testing.T, + expectedPrint map[LogLevel]bool, + expectedString string, + logFunc LogFunction, +) { + testMsg := "Perfectly balanced" + memLog := bytes.Buffer{} + log.SetOutput(&memLog) + + prevLogLevel := lobbySettings.logLevel + defer func() { lobbySettings.logLevel = prevLogLevel }() + defer log.SetOutput(os.Stderr) // os.Stderr is the default output + + for _, l := range logLevels { + lobbySettings.logLevel = l + logFunc(testMsg) + printed := false + mll := memLog.Len() + if mll != 0 { + printed = true + } + shouldPrint, _ := expectedPrint[l] + if shouldPrint != printed { + t.Error("printed when it shouldn't") + } else if mll > 0 { + if !strings.HasSuffix(memLog.String(), testMsg+"\n") { + t.Log("log message:", memLog.String()) + t.Error("log message wasn't found") + } + if !strings.Contains(memLog.String(), expectedString) { + t.Log("log message:", memLog.String()) + t.Error("log level header not found") + } + } + memLog.Reset() + } +} + +func assertLogString(l LogLevel) error { + logLevelString := map[LogLevel]string{ + logVerboseDebug: "VerboseDebug", + logDebug: "Debug", + logInfo: "Info", + logWarning: "Warning", + logCritical: "Critical", + logUnknown: "Unknown", + } + + if logLevelString[l] != l.String() { + return fmt.Errorf("Log Level String didn't match. Expected: '%s', but got '%s'", logLevelString[l], l.String()) + } + + ll := getLogLevel(logLevelString[l]) + if ll != l { + return fmt.Errorf("Log Level derivation from String didn't match. Expected: '%s', but got '%s'", logLevelString[l], ll.String()) + } + + return nil +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..b125b94 --- /dev/null +++ b/main.go @@ -0,0 +1,183 @@ +package main + +import ( + "errors" + "flag" + "fmt" + "os" + "os/signal" + "syscall" +) + +var version string +var versionCheck bool + +// Hardcoded settings +var lobbySettings = app{ + // Application name + appName: "Lobby", + // Local config file path + configFilePath: "./lobby.conf", + // System config file path + systemConfigFilePath: "/etc/lobby/lobby.conf", + // Max healthcheck timer initial wait in milliseconds + maxHcTimerInit: 500, + // Seconds to wait for DNS recheck + defaultDnsTtl: 25, + // Number of signal interrupts after which the app just exits without waiting for the graceful shutdown to complete + sigIntCounterExit: 3, + // Default log level. Set to one of: Critical / Warning / Info / Debug / VerboseDebug + logLevel: logInfo, + // Support message + supportMsg: "In case you're in need of support make sure to check", + // Support channel to be printed on errors + supportChannel: "https://github.com/ipbuff/lobby", + // Exit message + outro: "Stopped load balancing traffic", +} + +type app struct { + appName string + configFilePath string + systemConfigFilePath string + maxHcTimerInit int + defaultDnsTtl uint32 + sigIntCounterExit uint8 + logLevel LogLevel + supportMsg string + supportChannel string + outro string +} + +// Global var initializations +var ( + sigIntCounter uint8 = 0 // Holds count of number of Interrupt signals received + sSig = make(chan struct{}) // Holds the system signal channel + dl string // Holds the debug level string +) + +// errUserPrint logs the received errors and provides any relevant additional information to the user +// It can also be used to sanitize or normalize the error messages if required +func errUserPrint(err error) { + LogCf("%v", err) + + LogIf("%s %s", lobbySettings.supportMsg, lobbySettings.supportChannel) +} + +// signalHandler deals with the received system signals +// SIGHUP +// - load balancer reconfiguration +// +// SIGINT +// - load balancer exits gracefully +// - it has a signal counter to abort the graceful exit and forcefully quit +// - sigIntCounterExit defines the number of SIGINTs to abort graceful exit and forcefully quit +// +// SIGTERM +// - load balancer exits gracefully +// - has no signal counter +func signalHandler(s os.Signal, sCh chan struct{}, lbs *[]*lb) { + LogCf("Received signal '%s'", fmt.Sprint(s)) + switch s { + case syscall.SIGHUP: + LogCf("Reconfiguring") + if err := lbReconfig(lbs); err != nil { + LogWf("Reconfiguration failed") + if errors.Is(err, errLbInit) || errors.Is(err, errLbEngineReconfig) { + LogIf("Previous configuration was retained") + } else if errors.Is(err, errLbEngineStart) || errors.Is(err, errLbEngineStop) { + LogWf("Something went wrong with the reconfiguration. This could mean that the current load balancer runtime might be running with unexpected configuration") + LogIf("Consider rolling back to the previously well-known healthy config. If the issue persists, increase the logging verbosity for further troubleshooting") + } + LogIf("%v", err) + } else { + LogIf("Reconfigured successfully") + } + case syscall.SIGINT: + sigIntCounter++ + if sigIntCounter > 1 && sigIntCounter < lobbySettings.sigIntCounterExit { + LogIf("SIGINT signal counter %d/%d", sigIntCounter, lobbySettings.sigIntCounterExit) + } else if sigIntCounter == lobbySettings.sigIntCounterExit { + LogCf("Graceful shutdown interrupted. SIGINT signal counter limit reached. Exiting") + os.Exit(130) + } else { + LogCf("Graceful shutdown initiated. To abort and forcefully quit, send more %d SIGINT's", lobbySettings.sigIntCounterExit) + sCh <- struct{}{} + } + case syscall.SIGTERM: + LogCf("Graceful shutdown initiated") + sCh <- struct{}{} + } +} + +// Graceful shutdown procedure +func shutdown(lbs []*lb) { + for _, l := range lbs { + // Stop load balancer + LogCf("Stopping load balancer engine '%s'", l.et.String()) + l.stop() + } + + LogIf("%s", lobbySettings.outro) +} + +func init() { + flag.StringVar(&lobbySettings.configFilePath, "c", lobbySettings.configFilePath, "define the config file path with: '-c /path/to/config/file.yaml'\n") + flag.StringVar(&dl, "l", lobbySettings.logLevel.String(), "define the verbosity level with: '-l critical/warning/info/debug/verboseDebug'\n") + flag.BoolVar(&versionCheck, "v", false, "prints version and exits\n") +} + +func main() { + flag.Parse() + + if *&versionCheck { + fmt.Printf("%s %s\n", lobbySettings.appName, version) + os.Exit(0) + } + + var lbs []*lb // Holds the load balancer engines + + // Welcome message + LogIf("%s %s", lobbySettings.appName, version) + + lobbySettings.logLevel = getLogLevel(dl) + + LogDf("Initializing load balancer") + lbs, err := lbInit() + if err != nil { + errUserPrint(err) + LogCf("Load Balancer initialization failed. Exiting") + os.Exit(1) + } + + LogDf("Initialization succeeded. Starting load balancer engines") + for _, l := range lbs { + if err = l.start(); err != nil { + errUserPrint(err) + LogCf("Load Balancer start-up failed. Exiting") + os.Exit(1) + } + } + + LogIf("Traffic being load balanced") + + // Create a channel which waits for a SIGHUP, SIGINT or SIGTERM system signals + osSigCh := make(chan os.Signal, 1) + signal.Notify(osSigCh, syscall.SIGHUP, syscall.SIGINT, syscall.SIGTERM) + + // The system signal channel is dealt through a go routine + go func() { + for { + select { + case s := <-osSigCh: + signalHandler(s, sSig, &lbs) + } + } + }() + + // Code continues from here when the shutdown procedure is initiated + <-sSig + + // Initiate shutdown procedure + shutdown(lbs) +} diff --git a/main_norace_test.go b/main_norace_test.go new file mode 100644 index 0000000..404edf4 --- /dev/null +++ b/main_norace_test.go @@ -0,0 +1,82 @@ +//go:build !race +// +build !race + +package main + +import ( + "bytes" + "fmt" + "log" + "os" + "strings" + "syscall" + "testing" + "time" +) + +func assertSignal(s os.Signal) error { + memLog := bytes.Buffer{} + log.SetOutput(&memLog) + defer log.SetOutput(os.Stderr) // os.Stderr is the default output + + expectedPrint := lobbySettings.outro + wt := 2 + + var lbs []*lb + signalHandler(s, sSig, &lbs) + time.Sleep(time.Duration(wt) * time.Second) + + if !strings.Contains(memLog.String(), expectedPrint) { + return fmt.Errorf("%s log level assertion failed", expectedPrint) + } + + return nil +} + +func TestSIGINT(t *testing.T) { + config := `lb: +` + testConfigPath := "/tmp/lobby_test_conf.yaml" + lobbySettings.configFilePath = testConfigPath + os.WriteFile(testConfigPath, []byte(config), 0400) + + go main() + + wt := 2 + time.Sleep(time.Duration(wt) * time.Second) + os.Remove(testConfigPath) + + if err := assertSignal(syscall.SIGINT); err != nil { + t.Errorf("Signal result assertion failed: %v", err) + } +} + +func TestSIGTERM(t *testing.T) { + config := `lb: + - engine: testEngine + targets: + - name: testReconfig + protocol: tcp + port: 8082 + upstream_group: + name: ug + distribution: round-robin + upstreams: + - name: testUpstream + host: 1.1.1.1 + port: 80 +` + testConfigPath := "/tmp/lobby_test_conf.yaml" + lobbySettings.configFilePath = testConfigPath + os.WriteFile(testConfigPath, []byte(config), 0400) + + go main() + + wt := 2 + time.Sleep(time.Duration(wt) * time.Second) + os.Remove(testConfigPath) + + if err := assertSignal(syscall.SIGTERM); err != nil { + t.Errorf("Signal result assertion failed: %v", err) + } +} diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..adb01b0 --- /dev/null +++ b/main_test.go @@ -0,0 +1,107 @@ +package main + +import ( + "bytes" + "fmt" + "log" + "os" + "strings" + "syscall" + "testing" + "time" +) + +func assertErrUserPrint(expectedPrint map[LogLevel]string) error { + memLog := bytes.Buffer{} + log.SetOutput(&memLog) + defer log.SetOutput(os.Stderr) // os.Stderr is the default output + + for l, p := range expectedPrint { + errUserPrint(fmt.Errorf(p)) + if !strings.Contains(memLog.String(), p) { + return fmt.Errorf("%s log level assertion failed", l) + } + } + + return nil +} + +func TestErrUserPrint(t *testing.T) { + expectedPrint := map[LogLevel]string{ + logCritical: "Testing err user print", + logInfo: lobbySettings.supportMsg, + } + + if err := assertErrUserPrint(expectedPrint); err != nil { + t.Errorf("errUserPrint test failed with failed assertion: %v", err) + } +} + +func TestSIGHUP(t *testing.T) { + config := `lb: +` + testConfigPath := "/tmp/lobby_test_conf.yaml" + lobbySettings.configFilePath = testConfigPath + os.WriteFile(testConfigPath, []byte(config), 0400) + + var err error + lbs, err := lbInit() + if err != nil { + t.Errorf("lbInit returned an unexpected error: '%v'", err) + } + + wt := 2 + time.Sleep(time.Duration(wt) * time.Second) + os.Remove(testConfigPath) + + config = `lb: + - engine: testEngine + targets: + - name: testReconfig + protocol: tcp + port: 8082 + upstream_group: + name: ug + distribution: round-robin + upstreams: + - name: testUpstream + host: 1.1.1.1 + port: 80 +` + os.WriteFile(testConfigPath, []byte(config), 0400) + + signalHandler(syscall.SIGHUP, sSig, &lbs) + time.Sleep(time.Duration(wt) * time.Second) + + if len(lbs) == 0 { + t.Errorf("SIGHUP reconfig failed") + } + os.Remove(testConfigPath) + + config = `lb: + - engine: turbo + targets: + - name: testReconfig + protocol: tcp + port: 8082 + upstream_group: + name: ug + distribution: round-robin + upstreams: + - name: testUpstream + host: 1.1.1.1 + port: 80 +` + os.WriteFile(testConfigPath, []byte(config), 0400) + + signalHandler(syscall.SIGHUP, sSig, &lbs) + time.Sleep(time.Duration(wt) * time.Second) + + if lbs[0].et.String() != "testEngine" { + t.Errorf("Expected engine type was 'testEngine', but got %v", lbs[0].et.String()) + } + + os.Remove(testConfigPath) + + lbs[0].stop() +} diff --git a/nftables.go b/nftables.go new file mode 100644 index 0000000..70f8dc7 --- /dev/null +++ b/nftables.go @@ -0,0 +1,1049 @@ +package main + +import ( + "errors" + "fmt" + "net" + "regexp" + "sync" + "time" + + "github.com/google/nftables" + "github.com/google/nftables/binaryutil" + "github.com/google/nftables/expr" + "golang.org/x/sys/unix" + "kernel.org/pub/linux/libs/security/libcap/cap" +) + +// Some hardcoded settings +const ( + antnSuffixTimeFormat = "03040502012006" // time format suffix to be used in the nft table name + lobbyNftTableNamePattern = `^%s-\d{%d}$` // nft table name pattern + nftFamily = nftables.TableFamilyINet // nft table family. INet means both IPv4 and IPv6 + ugFoModeNftNameSuffix = "-" // Suffix to be used on nftables for upstream groups chain name +) + +var ( + // nft table name to be used by nftables for the application + appNftTableName = lobbySettings.appName + // default 'postrouting' nftables chain priority + defaultPostrChainPrio = *nftables.ChainPriorityFilter + // default 'prerouting' nftables chain priority + defaultPrerChainPrio = *nftables.ChainPriorityNATDest + // regex string to match nft table name + lobbyNftTableNameRegex = fmt.Sprintf( + lobbyNftTableNamePattern, + regexp.QuoteMeta(lobbySettings.appName), + len(antnSuffixTimeFormat), + ) + // supported lb engine protocols and distribution modes + nftSuppCapabilities = map[lbProto]map[distMode]bool{ + lbProtoTcp: { + distModeRR: true, + }, + } +) + +// nftables struct +type nft struct { + table *nftables.Table // nftables table + postrChain *nftables.Chain // nftables 'postrouting' chain + postrChainPrio nftables.ChainPriority // nftables 'postrouting' chain priority + prerChain *nftables.Chain // nftables 'prerouting' chain + prerChainPrio nftables.ChainPriority // nftables 'prerouting' chain priority + m sync.Mutex // nftables changes mutex +} + +// NFT errors +var ( + errNftPrep = errors.New( + "Error while preparing nftables", + ) + errNftInit = errors.New( + "Error while initializing nftables", + ) + errNftReconfig = errors.New( + "Error while reconfiguring nftables", + ) + errNftAssert = errors.New( + "Error when asserting lb engine of type nft", + ) + errNftNetlinkConn = errors.New( + "Failed to create a netlink connection. This error is not expected. Check that your system has the nf_tables Linux kernel subsystem available and troubleshoot further", + ) + errNftFlush = errors.New( + "Error when requesting a nftables flush", + ) + errNftListTables = errors.New( + "Error when listing nft tables", + ) + errNftAddLbTable = errors.New( + "Error when creating load balancer nft table", + ) + errNftAddMasquerade = errors.New( + "Error when adding masquerade in nftables", + ) + errNftCleanMasquerade = errors.New( + "Error when cleaning masquerade rules in nftables", + ) + errNftUpdateUpstreamChain = errors.New( + "Error when updating upstream chain", + ) + errNftUpdateUpstream = errors.New( + "Error during nftables upstream update", + ) + errNftStop = errors.New( + "Error during nftables stop process", + ) + errNftPerm = errors.New( + "Error during nft lb engine permissions check", + ) + errNftPermCap = errors.New( + "When running as unprivileged user, then the app process capability must have 'e' (Effective) and 'p' (Permitted) flags set for NET_ADMIN and NET_RAW capabilities. On most linux systems this can be set with `setcap 'cap_net_admin,cap_net_raw+ep' /path/to/lobby`.\nRestart the load balancer nft engine after fixing the permissions or re-run the load balancer as a privileged/root user", + ) + errNftUpdateTarget = errors.New( + "Error updating target", + ) +) + +type nftFunc func(c *nftables.Conn) error // nft management functions declaration used for the pushNft wrapper function + +// pushNft is a wrapper function to manage system nftables +// It locks the nft mutex to manage concurrency +// Creates a netlink connection +// Executes the passed nft management functions +// And then nftables.Conn.Flush sends all buffered commands in a single batch +func (n *nft) pushNft(fns ...nftFunc) error { + // Lock nft mutex + n.m.Lock() + defer n.m.Unlock() + + // Netlink connection for querying and modifying nftables + c, err := nftables.New(nftables.AsLasting()) + if err != nil { + return fmt.Errorf("%w: %w", errNftNetlinkConn, err) + } + defer c.CloseLasting() + + // Call the provided functions with the nftables connection + for _, fn := range fns { + fn(c) + } + + // Push changes to nftables + if err := c.Flush(); err != nil { + return fmt.Errorf("%w: %w", errNftFlush, err) + } + + return nil +} + +// prepareNftables prepares the nftables for the load balancer +// It checks if there are nft tables which match the application nft tables name pattern +// If it finds a match it means this could be some leftover from a previous instance +// The leftovers can happen for instance upon some kind of crash or uncontrolled failure +// The function clears any nft tables which match the application nft talbes name pattern +// An errNftPrep error is returned in case of issues when connecting to the netlink, +// listing nft tables or flushing the nft changes +func (n *nft) prepareNftables() error { + // Get all nftables tables + prepNftFunc := func(c *nftables.Conn) error { + tables, err := c.ListTables() + if err != nil { + return fmt.Errorf("%w: %w: %w", errNftPrep, errNftListTables, err) + } + + regex := regexp.MustCompile(lobbyNftTableNameRegex) + + for _, t := range tables { + if regex.MatchString(t.Name) && t.Family == nftFamily { + LogDf( + "NFT: Found nft table '%s' with the table name matching the pattern (%s) lobby uses as nft table name. Deleting the existing table to not interfere", + t.Name, + lobbyNftTableNameRegex, + ) + c.DelTable(t) + } + } + return nil + } + n.pushNft(prepNftFunc) + + LogDf("NFT: nftables preparation completed") + + return nil +} + +// addLbTable adds a nftables table where the nftables load balancing will be setup +func (n *nft) addLbTable() error { + // nft table used for load balancing + addLbTableFunc := func(c *nftables.Conn) error { + n.table = c.AddTable(&nftables.Table{ + Family: nftFamily, + Name: appNftTableName + "-" + time.Now().Format(antnSuffixTimeFormat), + }) + + return nil + } + + err := n.pushNft(addLbTableFunc) + if err != nil { + return fmt.Errorf("%w: %w", errNftAddLbTable, err) + } + + return nil +} + +// start calls startOrReconfig as the same function can be used for either start or reconfig +func (n *nft) start(l *lb) error { + return n.startOrReconfig(l, false) +} + +// startOrReconfig is used to start or reconfig the nftables based on the load balancer current definition +func (n *nft) startOrReconfig(l *lb, refresh bool) error { + if !refresh { + LogDf("NFT: nft initialization requested") + err := n.prepareNftables() + if err != nil { + return fmt.Errorf("%w: %w", errNftInit, err) + } + n.postrChainPrio = defaultPostrChainPrio + n.prerChainPrio = defaultPrerChainPrio + } else { + LogDf("NFT: nft reconfig requested") + } + + err := n.addLbTable() + if err != nil { + return fmt.Errorf("%w: %w", errNftInit, err) + } + LogDf("NFT: added Load Balancer nftable '%s'", n.table.Name) + + // If refresh is true it means we're reconfiguring + // The priority of the postrouting and prerouting chains will be changed + // This is done so that during the reconfiguration transition there is no overlap + // as two tables coexist momentarily + if refresh { + // When reconfiguring, we want to insert the new config in parallel with a different priority + // before clearing the previous config. This is so that at no point in time during the transition + // the nft is left without either the old or the new config + if n.postrChainPrio == defaultPostrChainPrio { + LogDVf( + "NFT: 'postrouting' chain prio was %d", + n.postrChainPrio, + ) + // increment priority by one + n.postrChainPrio++ + LogDVf( + "NFT: 'postrouting' chain prio now set to %d", + n.postrChainPrio, + ) + } else { + LogDVf( + "NFT: 'postrouting'chain prio was %d", + n.postrChainPrio, + ) + // reset priority + n.postrChainPrio = defaultPostrChainPrio + LogDVf( + "NFT: 'postrouting' chain prio now set to %d", + n.postrChainPrio, + ) + } + + if n.prerChainPrio == defaultPrerChainPrio { + LogDVf( + "NFT: 'prerouting' chain prio was %d", + n.prerChainPrio, + ) + // increment priority by one + n.prerChainPrio++ + LogDVf( + "NFT: 'prerouting' chain prio now set to %d", + n.prerChainPrio, + ) + } else { + LogDVf( + "NFT: 'prerouting' chain prio was %d", + n.prerChainPrio, + ) + n.prerChainPrio = defaultPrerChainPrio + LogDVf( + "NFT: 'prerouting' chain prio now set to %d", + n.prerChainPrio, + ) + } + } else { + LogDf("NFT: nftables startup process") + } + + setMasqueradeFunc := func(c *nftables.Conn) error { + // NAT postrouting is required to set masquerade (SNAT) toward targets + n.postrChain = c.AddChain(&nftables.Chain{ + Name: "postrouting", + Table: n.table, + Type: nftables.ChainTypeNAT, + Hooknum: nftables.ChainHookPostrouting, + Priority: &n.postrChainPrio, + }) + + // Create masquerade nft rule for unique upstream IP addresses + uniqueUpstreamIps := findUniqueNetIp(l.upstreamIps) + for _, ip := range uniqueUpstreamIps { + c.AddRule(&nftables.Rule{ + Table: n.table, + Chain: n.postrChain, + Exprs: []expr.Any{ + &expr.Meta{ + Key: expr.MetaKeyNFPROTO, + Register: 1, + }, + &expr.Cmp{ + Op: expr.CmpOpEq, + Register: 1, + Data: []byte{unix.NFPROTO_IPV4}, + }, + &expr.Payload{ + DestRegister: 1, + Base: expr.PayloadBaseNetworkHeader, + Offset: 16, + Len: 4, + }, + &expr.Cmp{ + Op: expr.CmpOpEq, + Register: 1, + Data: ip.To4(), + }, + &expr.Masq{}, + }, + }) + } + return nil + } + + // NAT prerouting chain is required to set load balancing rules + addPrerChainFunc := func(c *nftables.Conn) error { + n.prerChain = c.AddChain(&nftables.Chain{ + Name: "prerouting", + Table: n.table, + Type: nftables.ChainTypeNAT, + Hooknum: nftables.ChainHookPrerouting, + Priority: &n.prerChainPrio, + }) + return nil + } + if err = n.pushNft(setMasqueradeFunc, addPrerChainFunc); err != nil { + if err != nil { + return fmt.Errorf("%w: %w", errNftInit, err) + } + } + + for _, t := range l.targets { + // Initialize blank nftUgSet, nftUgChain, nftUgChainRule, nftPrerRule + for i := 0; i < numUgFoModes; i++ { + t.upstreamGroup.nftUgSet = append(t.upstreamGroup.nftUgSet, &nftables.Set{}) + t.upstreamGroup.nftUgChain = append(t.upstreamGroup.nftUgChain, &nftables.Chain{}) + t.upstreamGroup.nftUgChainRule = append( + t.upstreamGroup.nftUgChainRule, + &nftables.Rule{}, + ) + t.nftPrerRule = append(t.nftPrerRule, &nftables.Rule{}) + } + + // Initialize upstreamGroup counter + t.upstreamGroup.nftCounter = &nftables.CounterObj{ + Table: n.table, + Name: t.name, + Bytes: 0, + Packets: 0, + } + + // Set nftables for target + if err = n.updateTarget(t); err != nil { + return fmt.Errorf("%w: %w", errNftInit, err) + } + } + + return nil +} + +// nftables load balancing is simply stopped by deleting the load balancer nftables table +// where all the load balancing is setup +func (n *nft) stop() error { + LogIf("NFT: a stop was requested. Initiating nftables cleanup") + + LogDf("NFT: deleting nft table '%s' created for traffic load balancing", n.table.Name) + err := n.pushNft(func(c *nftables.Conn) error { + c.DelTable(n.table) + return nil + }) + if err != nil { + return fmt.Errorf("%w: %w", errNftStop, err) + } + + return nil +} + +// getVmapElements returns a list of nftables.SetElement for a given target +// a nftables.SetElement in this context is a nftables veredict to jump to a upstream chain +func getVmapElements(t *target) *[]nftables.SetElement { + var vmapElements []nftables.SetElement + activeCount := 0 + + for _, u := range t.upstreamGroup.upstreams { + if u.available { + vmape := nftables.SetElement{ + Key: binaryutil.NativeEndian.PutUint16(uint16(activeCount)), + VerdictData: &expr.Verdict{ + Kind: unix.NFT_JUMP, + Chain: u.name, + }, + } + + vmapElements = append(vmapElements, vmape) + activeCount++ + } + } + + return &vmapElements +} + +// numActiveUpstreams returns the number of active upstreams +func numActiveUpstreams(t *target) uint16 { + nActiveUpstreams := uint16(0) + for _, u := range t.upstreamGroup.upstreams { + if u.available { + nActiveUpstreams++ + } + } + + return nActiveUpstreams +} + +// updateTarget updates the nftables for a given lb target +func (n *nft) updateTarget(t *target) error { + LogIf( + "NFT: Setting nftables for target '%s' (protocol %s on port %d)", + t.name, + t.protocol.String(), + t.port, + ) + + // Lock nft mutex + n.m.Lock() + defer n.m.Unlock() + + // Netlink connection for querying and modifying nftables + c, err := nftables.New(nftables.AsLasting()) + if err != nil { + return fmt.Errorf("%w: %w", errNftUpdateTarget, errNftNetlinkConn) + } + defer c.CloseLasting() + + // Get number of active upstreams + nActiveUpstreams := numActiveUpstreams(t) + // Number of configured upstreams + numUpstreams := uint16(len(t.upstreamGroup.upstreams)) + if nActiveUpstreams == 0 { + LogIf("NFT: No upstreams available for target '%s'\n", t.name) + // Given there are no available upstreams, record previous failoverMode and + // set the failover mode to ugFoModeDown + t.upstreamGroup.previousFailoverMode = t.upstreamGroup.failoverMode + t.upstreamGroup.failoverMode = ugFoModeDown + } else if nActiveUpstreams == numUpstreams { + LogIf("NFT: All %d upstreams available for target '%s'\n", numUpstreams, t.name) + // Given all upstreams are up, record the revious failoverMode and + // set the failover mode to ugFoModeInactive + t.upstreamGroup.previousFailoverMode = t.upstreamGroup.failoverMode + t.upstreamGroup.failoverMode = ugFoModeInactive + + } else { + LogIf("NFT: %d/%d upstreams available for target '%s'\n", nActiveUpstreams, numUpstreams, t.name) + // Given not all upstreams are available and there are no available upstreams, + // record previous failoverMode and set the failverMode to the next failoverMode + t.upstreamGroup.previousFailoverMode = t.upstreamGroup.failoverMode + t.upstreamGroup.failoverMode, _ = t.upstreamGroup.failoverMode.nextMode() + } + + ugFM := t.upstreamGroup.failoverMode + ugName := t.upstreamGroup.name + ugFoModeNftNameSuffix + ugFM.getId() + + // New failover chain + t.upstreamGroup.nftUgChain[ugFM] = c.AddChain(&nftables.Chain{ + Name: ugName, + Table: n.table, + }) + + // Ensure upstream chains exist + // Needed at nftables initalization + chains, _ := c.ListChains() + for _, u := range t.upstreamGroup.upstreams { + chainFound := false + for _, chain := range chains { + if chain.Name == u.name && chain.Table.Name == n.table.Name { + LogDVf("NFT: Found chain %s in table %s", chain.Name, chain.Table.Name) + chainFound = true + break + } else { + LogDVf("NFT: no match") + LogDVf("NFT: nft table name %s chain name %s", chain.Table.Name, chain.Name) + LogDVf("NFT: config table name %s chain name %s", n.table.Name, u.name) + } + } + if !chainFound { + LogDf("NFT: Setting up chain for upstream %s in table %s", u.name, n.table.Name) + LogDf("NFT: Upstream address is %s:%d", u.address.String(), u.port) + if u.address != nil { + c.AddRule(&nftables.Rule{ + Table: n.table, + Chain: c.AddChain(&nftables.Chain{ + Name: u.name, + Table: n.table, + }), + Exprs: []expr.Any{ + &expr.Immediate{ + Register: 1, + Data: u.address.To4(), + }, + &expr.Immediate{ + Register: 2, + Data: binaryutil.BigEndian.PutUint16(u.port), + }, + &expr.NAT{ + Type: expr.NATTypeDestNAT, + Family: unix.NFPROTO_IPV4, + RegAddrMin: 1, + RegAddrMax: 0, + RegProtoMin: 2, + RegProtoMax: 0, + }, + }, + }) + } + } + } + + // New set with active upstreams + t.upstreamGroup.nftUgSet[ugFM] = &nftables.Set{ + Name: ugName, + Table: n.table, + KeyType: nftables.TypeInetService, + DataType: nftables.TypeVerdict, + IsMap: true, + } + vmapElements := getVmapElements(t) + c.AddSet(t.upstreamGroup.nftUgSet[ugFM], *vmapElements) + + // New failover chain rule + if nActiveUpstreams == 0 { + t.upstreamGroup.nftUgChainRule[ugFM] = c.AddRule(&nftables.Rule{ + Table: n.table, + Chain: t.upstreamGroup.nftUgChain[ugFM], + Exprs: []expr.Any{ + &expr.Reject{}, + }, + }) + } else { + t.upstreamGroup.nftUgChainRule[ugFM] = c.AddRule(&nftables.Rule{ + Table: n.table, + Chain: t.upstreamGroup.nftUgChain[ugFM], + Exprs: []expr.Any{ + &expr.Numgen{ + Register: 1, + Type: unix.NFT_NG_INCREMENTAL, + Modulus: uint32(len(*vmapElements)), + Offset: 0, + }, + &expr.Lookup{ + SourceRegister: 1, + DestRegister: 0, + SetName: t.upstreamGroup.nftUgSet[ugFM].Name, + SetID: t.upstreamGroup.nftUgSet[ugFM].ID, + IsDestRegSet: true, + }, + }, + }) + } + + // Check if counter objects already exist + _, err = c.GetObject(t.upstreamGroup.nftCounter) + if err != nil { + c.AddObj(t.upstreamGroup.nftCounter) + } + + // Check if prerouting chain is empty + // Check is needed at nftables initalization + if !t.nftRuleInit { + LogDf( + "NFT: prerouting chain needs to be initialized for target %s. Redirected to upstream group %s", + t.name, + ugName, + ) + t.nftPrerRule[ugFM] = c.AddRule(&nftables.Rule{ + Table: n.table, + Chain: n.prerChain, + Exprs: []expr.Any{ + &expr.Meta{ + Key: expr.MetaKeyL4PROTO, + Register: 1, + }, + &expr.Cmp{ + Op: expr.CmpOpEq, + Register: 1, + Data: []byte{unix.IPPROTO_TCP}, + }, + &expr.Payload{ + DestRegister: 1, + Base: expr.PayloadBaseTransportHeader, + Offset: 2, + Len: 2, + }, + &expr.Cmp{ + Op: expr.CmpOpEq, + Register: 1, + Data: binaryutil.BigEndian.PutUint16(t.port), + }, + &expr.Objref{ + Type: 1, + Name: t.upstreamGroup.nftCounter.Name, + }, + &expr.Verdict{ + Kind: expr.VerdictKind(unix.NFT_JUMP), + Chain: ugName, + }, + }, + }) + + t.nftRuleInit = true + } else { + // prerouting rule update to new chain + rules, _ := c.GetRules(n.table, n.prerChain) + + for _, r := range rules { + if t.nftPrerRule[t.upstreamGroup.previousFailoverMode].Exprs[5].(*expr.Verdict).Chain == r.Exprs[5].(*expr.Verdict).Chain { + t.nftPrerRule[ugFM] = c.ReplaceRule(&nftables.Rule{ + Table: n.table, + Chain: n.prerChain, + Handle: r.Handle, + Exprs: []expr.Any{ + // [ meta load l4proto => reg 1 ] + &expr.Meta{ + Key: expr.MetaKeyL4PROTO, + Register: 1, + }, + // [ cmp eq reg 1 0x00000006 ] + &expr.Cmp{ + Op: expr.CmpOpEq, + Register: 1, + Data: []byte{unix.IPPROTO_TCP}, + }, + // [ payload load 2b @ transport header + 2 => reg 1 ] + &expr.Payload{ + DestRegister: 1, + Base: expr.PayloadBaseTransportHeader, + Offset: 2, + Len: 2, + }, + // [ cmp eq reg 1 0x0000901f ] + &expr.Cmp{ + Op: expr.CmpOpEq, + Register: 1, + Data: binaryutil.BigEndian.PutUint16(t.port), + }, + // [ objref type 1 name counterName ] + &expr.Objref{ + Type: 1, + Name: t.upstreamGroup.nftCounter.Name, + }, + // [ immediate reg 0 jump -> chain ] + &expr.Verdict{ + Kind: expr.VerdictKind(unix.NFT_JUMP), + Chain: t.upstreamGroup.nftUgChain[ugFM].Name, + }, + }, + }) + } + } + } + + // Cleanup previous failover mode chain + // Loop through all chains is needed to prevent null pointers at nftables initialization + for _, chain := range chains { + if chain.Name == t.upstreamGroup.nftUgChain[t.upstreamGroup.previousFailoverMode].Name && + chain.Table.Name == n.table.Name { + c.DelChain(t.upstreamGroup.nftUgChain[t.upstreamGroup.previousFailoverMode]) + } + } + + // Cleanup previous failover mode set + // Loop through all sets is needed to prevent null pointers at nftables initialization + sets, _ := c.GetSets(n.table) + for _, s := range sets { + if s.Name == t.upstreamGroup.nftUgSet[t.upstreamGroup.previousFailoverMode].Name && + s.Table.Name == n.table.Name { + c.DelSet(t.upstreamGroup.nftUgSet[t.upstreamGroup.previousFailoverMode]) + } + } + + if err := c.Flush(); err != nil { + return fmt.Errorf("%w: %w", errNftUpdateTarget, errNftFlush) + } + + return nil +} + +// addMasquerade adds a masquerade rule on the nftables for a given IP address +// it checks if a masquerade rule already exists for that IP and adds if not +func (n *nft) addMasquerade(ip *net.IP) error { + LogDf("NFT: Add masquerade for '%s' requested", ip.String()) + + addMasqueradeFunc := func(c *nftables.Conn) error { + pr, err := c.GetRules( + n.table, + n.postrChain, + ) + if err != nil { + LogWf( + "NFT: get rules nftables operation failed. This is not expected and the load balancer might be misconfigured as a result. Manual troubleshooting is likely required. Consider increasing load balancer verbosity to troubleshoot further", + ) + } + + // Check if it is necessary to add masquerade + needsAdding := true + for _, rule := range pr { + rip := net.IP(rule.Exprs[3].(*expr.Cmp).Data) + if rip.Equal(*ip) { + needsAdding = false + break + } + } + + if needsAdding { + LogDVf("NFT: It is necessary to add masquerade") + // Add masquerade in 'postrouting' chain for upstream IP + c.AddRule(&nftables.Rule{ + Table: n.table, + Chain: n.postrChain, + Exprs: []expr.Any{ + &expr.Meta{ + Key: expr.MetaKeyNFPROTO, + Register: 1, + }, + &expr.Cmp{ + Op: expr.CmpOpEq, + Register: 1, + Data: []byte{unix.NFPROTO_IPV4}, + }, + &expr.Payload{ + DestRegister: 1, + Base: expr.PayloadBaseNetworkHeader, + Offset: 16, + Len: 4, + }, + &expr.Cmp{ + Op: expr.CmpOpEq, + Register: 1, + Data: ip.To4(), + }, + &expr.Masq{}, + }, + }) + } else { + LogDVf("NFT: It is not necessary to add masquerade as it already exists") + } + return nil + } + + err := n.pushNft(addMasqueradeFunc) + if err != nil { + return fmt.Errorf("%w: %w", errNftAddMasquerade, err) + } + + return nil +} + +// cleanMasquerade deletes nftables masquerade rules +// that are not found or that are duplicate for the given slice of net.IP +func (n *nft) cleanMasquerade(lip *[]net.IP) error { + LogDf("NFT: masquerade rule clean up was requested") + + cleanMasqueradeFunc := func(c *nftables.Conn) error { + // Get 'postrouting' chain rules + pr, err := c.GetRules( + n.table, + n.postrChain, + ) + if err != nil { + LogWf( + "NFT: get rules nftables operation failed. This is not expected and the load balancer might be misconfigured as a result. Manual troubleshooting is likely required. Consider increasing load balancer verbosity to troubleshoot further", + ) + } + + // Delete unnecessary masquerade rules + rulesLoop: + for _, rule := range pr { + rip := net.IP(rule.Exprs[3].(*expr.Cmp).Data) + for _, ip := range *lip { + if rip.Equal(ip) { + continue rulesLoop + } + } + LogDVf("NFT: masquerade rule for '%s' is no longer necessary. Deleting", rip.String()) + c.DelRule(rule) + } + + // Delete duplicate masquerade rules + dnipl := findDuplicateNetIp(lip) + for _, dip := range dnipl { + for _, rule := range pr { + rip := net.IP(rule.Exprs[3].(*expr.Cmp).Data) + if dip.Equal(rip) { + LogDVf("NFT: removing duplicate masquerade entry for '%s'", rip.String()) + c.DelRule(rule) + } + } + } + return nil + } + + err := n.pushNft(cleanMasqueradeFunc) + if err != nil { + return fmt.Errorf("%w: %w", errNftCleanMasquerade, err) + } + + return nil +} + +// updateUpstreamChain updates the nftables upstream chain +// It checks if the upstream chain rule already exists and adds if not +// Otherwise, it replaces the existing rule +// Upstream chains always have a single rule which is why +// it is ok to [0] the chain rules +func (n *nft) updateUpstreamChain(u *upstream) error { + LogDf("NFT: update for upstream '%s' chain requested", u.name) + + updateUpstreamChainFunc := func(c *nftables.Conn) error { + // Get upstream chain rules + ucr, err := c.GetRules( + n.table, + &nftables.Chain{ + Name: u.name, + }, + ) + if err != nil { + LogWf( + "NFT: get rules nftables operation failed. This is not expected and the load balancer might be misconfigured as a result. Manual troubleshooting is likely required. Consider increasing load balancer verbosity to troubleshoot further", + ) + } + + if len(ucr) != 0 { + LogDVf("NFT: upstream chain rule already exists. Replacing existing chain") + c.ReplaceRule(&nftables.Rule{ + Table: n.table, + Chain: ucr[0].Chain, + Handle: ucr[0].Handle, + Exprs: []expr.Any{ + &expr.Immediate{ + Register: 1, + Data: u.address.To4(), + }, + &expr.Immediate{ + Register: 2, + Data: binaryutil.BigEndian.PutUint16(u.port), + }, + &expr.NAT{ + Type: expr.NATTypeDestNAT, + Family: unix.NFPROTO_IPV4, + RegAddrMin: 1, + RegAddrMax: 0, + RegProtoMin: 2, + RegProtoMax: 0, + }, + }, + }) + } else { + LogDVf("NFT: upstream chain rule does not exists yet. Adding chain") + c.AddRule(&nftables.Rule{ + Table: n.table, + Chain: c.AddChain(&nftables.Chain{ + Name: u.name, + Table: n.table, + }), + Exprs: []expr.Any{ + &expr.Immediate{ + Register: 1, + Data: u.address.To4(), + }, + &expr.Immediate{ + Register: 2, + Data: binaryutil.BigEndian.PutUint16(u.port), + }, + &expr.NAT{ + Type: expr.NATTypeDestNAT, + Family: unix.NFPROTO_IPV4, + RegAddrMin: 1, + RegAddrMax: 0, + RegProtoMin: 2, + RegProtoMax: 0, + }, + }, + }) + } + return nil + } + + err := n.pushNft(updateUpstreamChainFunc) + if err != nil { + return fmt.Errorf("%w: %w", errNftUpdateUpstreamChain, err) + } + + return nil +} + +// updateUpstream refreshes the upstream nftables rules +// It first adds the masquerade rule for the upstream IP address +// Then it updates the upstream nftables chain +// Lastly, it performs a masquerade rules cleanup +// The cleanup is required to be done only after the upstream chain update +// to ensure that traffic is not disrupted by removing the masquerade before the new +// upstream rules are configured +func (n *nft) updateUpstream(u *upstream, auip *[]net.IP) error { + LogDf("NFT: update for upstream '%s' requested", u.name) + + // All upstream IPs must have a masquerade rule in 'postrouting' chain + err := n.addMasquerade(&u.address) + if err != nil { + return fmt.Errorf("%w: %w", errNftUpdateUpstream, err) + } + + // Update upstream chain rules + err = n.updateUpstreamChain(u) + + // Clean up the masquerade rules + err = n.cleanMasquerade(auip) + if err != nil { + return fmt.Errorf("%w: %w", errNftUpdateUpstream, err) + } + + return nil +} + +// reconfig receives the new load balancer and deals with the nftables transition +// from the previous configuration to the new one +func (n *nft) reconfig(nl *lb) error { + LogDVf("NFT: nft reconfig was requested") + nn, ok := nl.e.(*nft) // assert if it is a nftables lb engine + if !ok { + return fmt.Errorf("%w: %w", errNftReconfig, errNftAssert) + } + + // the new postrouting and prerouting nftables priorities are set to the value + // of the previous load balancer nftables priorities so these can be assessed + // as part of the reconfig method. This causes the previous config and the + // new config to run simultaneously, but on different priorities to ensure that there + // is no interruption to the traffic during the transition between the old and new config + nn.postrChainPrio = n.postrChainPrio + nn.prerChainPrio = n.prerChainPrio + + // request a reconfig for the new lb + if err := nn.startOrReconfig(nl, true); err != nil { + return fmt.Errorf("%w: %w", errNftReconfig, err) + } + + // stop the old lb now that the new has been successfully configured + if err := n.stop(); err != nil { + return fmt.Errorf("%w: %w", errNftReconfig, err) + } + + LogDVf("NFT: nft reconfig was successfully completed") + return nil +} + +// getCapabilities provides the nftables supported lb capabilities +func (n *nft) getCapabilities() map[lbProto]map[distMode]bool { + return nftSuppCapabilities +} + +// checkPermissions checks if the minimum required permissions have been granted +// so the load balancer can run successfully. Otherwise, it returns a errCheckPerm error +// CAP_NET_ADMIN and CAP_NET_RAW capabilities are required +// To run as unprivileged user give the required capabilities by +// running the following command as privileged user: +// `setcap 'cap_net_admin,cap_net_raw+ep' ` +// Where is this application binary path +// It is possible to check the capabilities of the binary with: +// `getcap ` +func (n *nft) checkPermissions() error { + // Get running process + cs := cap.GetProc() + + // Capabilities to be checked + caps := []cap.Value{cap.NET_ADMIN, cap.NET_RAW} + // Capability set to be checked + flags := []cap.Flag{cap.Effective, cap.Permitted} + + // For each capability to be checked, check if the capability set is set + for _, c := range caps { + LogDVf("NFT: permission check checking '%s' capability", c) + for _, f := range flags { + LogDVf("NFT: permission check: checking capability '%s' capability set '%s' ", c, f) + err := checkCapabilities(cs, f, c) + if err != nil { + return fmt.Errorf("%w: %w: \n\n%w\n\n", errNftPerm, err, errNftPermCap) + } + } + } + + LogDf("NFT: permissions check succeeded") + return nil +} + +// This function checks if all the dependencies are satisfied for the nft load balancer to be able to operate successfully +// A check is performed on the IPv4 and IPv6 IP forwarding as this is required for the nftables to be able to process +// traffic to other IP addresses that do not belong to the server which is hosting the Load Balancer +// However, in case it is not confirmed that IPv4 and IPv6 are enabled the Load Balancer an error will not be returned +// It will just log a warning message, because there are scenarios in which the Load Balancer can be used without +// requiring IP packets to be forward to another server +func (n *nft) checkDependencies() error { + LogDf("Dependencies check: checking if IP forwarding is system enabled") + ipF, err := checkIpFwd() + if err != nil { + return err + } + + switch ipF { + case ipFwdUnknown: + LogWf("Dependencies check: skipped IP forwarding settings check") + case ipFwdNone: + LogWf( + "Dependencies check: IPv4 Forwarding Check Result: FAILED. IPv4 forwarding seems to be generally disabled at system level. This is not a critical error, but the Load Balancer may not function as expected for IPv4 traffic use cases. Make sure the IPv4 forwarding is correctly configured to prevent any issues", + ) + LogWf( + "Dependencies check: IPv6 Forwarding Check Result: FAILED. IPv6 forwarding seems to be generally disabled at system level. This is not a critical error, but the Load Balancer may not function as expected for IPv6 traffic use cases. Make sure the IPv6 forwarding is correctly configured to prevent any issues", + ) + case ipFwdAll: + LogDf( + "Dependencies check: IPv4 Forwarding Check Result: SUCCEEDED. IPv4 forwarding seems to be generally enabled", + ) + LogDf( + "Dependencies check: IPv6 Forwarding Check Result: SUCCEEDED. IPv6 forwarding seems to be generally enabled", + ) + case ipFwdV4Only: + LogDf( + "Dependencies check: IPv4 Forwarding Check Result: SUCCEEDED. IPv4 forwarding seems to be generally enabled", + ) + LogWf( + "Dependencies check: IPv6 Forwarding Check Result: FAILED. IPv6 forwarding seems to be generally disabled at system level. This is not a critical error, but the Load Balancer may not function as expected for IPv6 traffic use cases. Make sure the IPv6 forwarding is correctly configured to prevent any issues", + ) + case ipFwdV6Only: + LogWf( + "Dependencies check: IPv4 Forwarding Check Result: FAILED. IPv4 forwarding seems to be generally disabled at system level. This is not a critical error, but the Load Balancer may not function as expected for IPv4 traffic use cases. Make sure the IPv4 forwarding is correctly configured to prevent any issues", + ) + LogDf( + "Dependencies check: IPv6 Forwarding Check Result: SUCCEEDED. IPv6 forwarding seems to be generally enabled", + ) + } + + LogDf("Dependencies check completed") + return nil +} diff --git a/scripts/getLobby.sh b/scripts/getLobby.sh new file mode 100644 index 0000000..dbdd931 --- /dev/null +++ b/scripts/getLobby.sh @@ -0,0 +1,160 @@ +#!/bin/sh +SCRIPT_NAME='getLobby' + +LOBBY_BIN_URL='https://github.com/ipbuff/lobby/releases/latest/download' +LOBBY_AMD64_BIN_URL="${LOBBY_BIN_URL}/lobby-linux-amd64" +LOBBY_ARM64_BIN_URL="${LOBBY_BIN_URL}/lobby-linux-arm64" +LOBBY_DEMO_CONFIG_URL='https://raw.githubusercontent.com/ipbuff/lobby/main/docs/examples/demo.conf' + +CONF_PATH_LOCAL='./lobby.conf' +CONF_PATH_ETC='/etc/lobby/lobby.conf' + +USE_WGET=1 # 0 is false, anything else is true +GREEN='\033[0;32m' +MAGENTA='\033[0;35m' +RED='\033[0;31m' +NC='\033[0m' # No color + +print_introl() { + printf '+-------------------------------------------------------+\n' + printf '| Lobby - A Load Balancer based on nftables |\n' + printf '| |\n' + printf '| The Lobby binary will be downloaded to this directory |\n' + printf '+-------------------------------------------------------+\n' + printf '\n' +} + +print_intros() { + printf 'Lobby - A Load Balancer based on nftables\n' + printf '\n' + printf 'The Lobby binary will be downloaded to this directory\n' + printf '\n' +} + +intro() { + if tput cols > /dev/null 2>&1; then + cols=$(tput cols) + if [ "${cols}" -ge 60 ]; then + print_introl + else + print_intros + fi + else + print_intros + fi +} + +failPrint() { + printf '\n%s cancelled\n' "$SCRIPT_NAME" +} + +checkDep() { + # echo 'Checking script dependencies' + if ! wget --version > /dev/null 2>&1; then + printf ''\''wget'\'' not available. Will try '\''curl'\'' instead\n' + USE_WGET=0 + if ! curl --version > /dev/null 2>&1; then + printf ''\''curl'\'' also not available\n' + printf '\n' + printf '%b'\''curl'\'' or '\''wget'\'' are dependencies for this script%b\n' "$RED" "$NC" + failPrint + exit 1 + fi + fi + # echo ' Dependencies successfully checked' +} + +checkArch() { + # echo 'Checking system architecture compatibility' + ARCH=$(uname -a | awk '{ print $(NF-1) }') + if [ "$ARCH" = "aarch64" ]; then + DOWNLOAD_URL="${LOBBY_ARM64_BIN_URL}" + elif [ "$ARCH" = "x86_64" ]; then + DOWNLOAD_URL="${LOBBY_AMD64_BIN_URL}" + else + printf '\n' + printf '%bLobby is incompatible with %s%b\n' "$RED" "$ARCH" "$NC" + failPrint + exit 1 + fi + # echo " Check successful. System is '$ARCH'" +} + +checkLobbyDep() { + IPV4_FWD=$(sysctl net.ipv4.ip_forward | awk '{ print $3 }') +} + +download() { + if [ "$USE_WGET" -ne 0 ]; then + if ! wget -q -O "$1" "$2"; then + return 1 + fi + else + if ! curl -L -s -o "$1" "$2"; then + return 1 + fi + fi + + return 0 +} +downloadLobbyBin() { + printf 'Downloading Lobby binary for %s system\n' "$ARCH" + if ! download lobby ${DOWNLOAD_URL}; then + printf '\n' + printf 'Failed to download the Lobby binary from %s\n' "$DOWNLOAD_URL" + rm -rf lobby + failPrint + exit 1 + fi + + chmod 770 ./lobby +} + +prepConf() { + if ! [ -f ${CONF_PATH_LOCAL} ]; then + if ! [ -f ${CONF_PATH_ETC} ]; then + # echo "Config file not found. Checked in '${CONF_PATH_LOCAL}' and '${CONF_PATH_ETC}'" + # echo " Creating config file in local path '${CONF_PATH_LOCAL}'" + if ! download lobby.conf ${LOBBY_DEMO_CONFIG_URL}; then + printf '\n' + printf 'Failed to download the Lobby demo config file from %s\n' "$LOBBY_DEMO_CONFIG_URL" + rm -rf lobby + failPrint + exit 1 + fi + fi + fi +} + +outro() { + printf '\n' + printf 'Lobby was successfully prepared at this folder (%s)\n' "$(pwd)" + printf '\n' + printf 'A demo Lobby configuration for testing purposes has been prepared at '%s/lobby.conf'\n' "$(pwd)" + printf 'Feel free to run Lobby with that demo configuration or by adjusting the config file\n' + printf '\n' + printf '%bConsider running Lobby as '\''root'\''.%b As an alternative, it is possible to run Lobby as an unprivileged user as long as the binary is given the '\''NET_ADMIN'\'' and '\''NET_RAW'\'' Linux capabilities. This can be achieved by running the following command as '\''root'\'':\n' "$MAGENTA" "$NC" + printf ' # setcap '\''cap_net_admin,cap_net_raw+ep'\'' %s/lobby\n' "$(pwd)" + printf '\n' + if [ "$IPV4_FWD" -ne 1 ]; then + printf '%bYour system seems not to have IP forwarding enabled.%b\nLobby will not be able to load balance traffic if the host doesn'\''t has IP forwarding enabled.\n' "$RED" "$NC" + printf '\n' + fi + printf '%bLobby can be started with the following command:%b\n' "$GREEN" "$NC" + printf ' './lobby'\n' + printf '\n' +} + +intro + +checkDep + +checkArch + +checkLobbyDep + +downloadLobbyBin + +prepConf + +outro diff --git a/scripts/installLobby.sh b/scripts/installLobby.sh new file mode 100644 index 0000000..4fe1e74 --- /dev/null +++ b/scripts/installLobby.sh @@ -0,0 +1,257 @@ +#!/bin/sh +SCRIPT_NAME='installLobby' + +LOBBY_BIN_URL='https://github.com/ipbuff/lobby/releases/latest/download' +LOBBY_AMD64_BIN_URL="${LOBBY_BIN_URL}/lobby-linux-amd64" +LOBBY_ARM64_BIN_URL="${LOBBY_BIN_URL}/lobby-linux-arm64" +LOBBY_DEMO_CONFIG_URL='https://raw.githubusercontent.com/ipbuff/lobby/main/docs/examples/demo.conf' +LOBBY_SERVICE_URL='https://raw.githubusercontent.com/ipbuff/lobby/main/docs/examples/lobby.service' + +LOBBY_BIN_DIR='/usr/local/bin' +LOBBY_BIN_NAME='lobby' +LOBBY_BIN_PATH=${LOBBY_BIN_DIR}/${LOBBY_BIN_NAME} +LOBBY_CONF_DIR='/etc/lobby' +LOBBY_CONF_NAME='lobby.conf' +LOBBY_CONF_PATH=${LOBBY_CONF_DIR}/${LOBBY_CONF_NAME} +LOBBY_ROOT_SERVICE_PATH='/etc/systemd/system/lobby.service' + +INIT=$(cat /proc/1/comm) # init system. systemd/other +USE_WGET=1 # 0 is false, anything else is true + +GREEN='\033[0;32m' +RED='\033[0;31m' +NC='\033[0m' # No color + +print_introl() { + printf '+-----------------------------------------------+\n' + printf '| Lobby - A Load Balancer based on nftables |\n' + printf '| |\n' + printf '| Lobby systemd service installer |\n' + printf '+-----------------------------------------------+\n' + printf '\n' +} + +print_intros() { + printf 'Lobby - A Load Balancer based on nftables\n' + printf '\n' + printf 'Lobby systemd service installer\n' + printf '\n' +} + +intro() { + if tput cols > /dev/null 2>&1; then + cols=$(tput cols) + if [ "${cols}" -ge 50 ]; then + print_introl + else + print_intros + fi + else + print_intros + fi +} + +failPrint() { + printf '\n%s cancelled\n' ${SCRIPT_NAME} +} + +parseArgs() { + if [ "$1" = "" ]; then + OWNER=root + else + if id "$1" > /dev/null 2>&1; then + OWNER=$1 + else + printf '%bThe provided username '\''%s'\'' was not found. Provide an existing username%b\n' "$RED" "$1" "$NC" + failPrint + exit 1 + fi + fi + printf 'Lobby will be installed for '\''%s'\'' user\n' "$OWNER" +} + +checkDeps() { + if [ ! "$INIT" = "systemd" ]; then + printf '%bNot a '\''systemd'\'' init type system. This install script can'\''t be used%b\n' "$RED" "$NC" + failPrint + exit 1 + fi + + if [ "$(id -u)" -ne 0 ]; then + printf '%bThis installer must be run as '\''root'\'' user%b\n' "$RED" "$NC" + failPrint + exit 1 + fi + + if ! wget --version > /dev/null 2>&1; then + printf ''\''wget'\'' not available. Will try '\''curl'\'' instead\n' + USE_WGET=0 + if ! curl --version > /dev/null 2>&1; then + printf ''\''curl'\'' also not available\n' + printf '\n' + printf '%b'\''curl'\'' or '\''wget'\'' are dependencies for this script%b\n' "$RED" "$NC" + failPrint + exit 1 + fi + fi + +} + +checkArch() { + # echo 'Checking system architecture compatibility' + ARCH=$(uname -a | awk '{ print $(NF-1) }') + if [ "$ARCH" = "aarch64" ]; then + DOWNLOAD_URL="$LOBBY_ARM64_BIN_URL" + elif [ "$ARCH" = "x86_64" ]; then + DOWNLOAD_URL="$LOBBY_AMD64_BIN_URL" + else + printf '\n' + printf '%bLobby %s is incompatible with %s%b\n' "$RED" "$LOBBY_VERSION" "$ARCH" "$NC" + failPrint + exit 1 + fi + # echo " Check successful. System is '$ARCH'" +} + +download() { + if [ "$USE_WGET" -ne 0 ]; then + if ! wget -q -O "$1" "$2"; then + return 1 + fi + else + if ! curl -L -s -o "$1" "$2"; then + return 1 + fi + fi + + return 0 +} + +downloadLobbyBin() { + printf 'Downloading Lobby binary for %s system to %s\n' "$ARCH" "$LOBBY_BIN_PATH" + if ! download ${LOBBY_BIN_PATH} ${DOWNLOAD_URL}; then + printf '\n' + printf 'Failed to download the Lobby binary from %s\n' "$DOWNLOAD_URL" + failPrint + exit 1 + fi + + chown "${OWNER}:${OWNER}" "$LOBBY_BIN_PATH" + chmod 770 ${LOBBY_BIN_PATH} + setcap 'cap_net_admin,cap_net_raw+ep' ${LOBBY_BIN_PATH} +} + +prepConfig() { + printf 'Downloading Lobby demo config file to %s\n' "$LOBBY_CONF_PATH" + + mkdir $LOBBY_CONF_DIR > /dev/null 2>&1 + chown "${OWNER}:${OWNER}" "$LOBBY_CONF_DIR" + chmod 770 ${LOBBY_CONF_DIR} + + if [ ! -f ${LOBBY_CONF_PATH} ]; then + if ! download ${LOBBY_CONF_PATH} ${LOBBY_DEMO_CONFIG_URL}; then + printf '\n' + printf 'Failed to download the Lobby demo config file from %s\n' "$LOBBY_DEMO_CONFIG_URL" + failPrint + exit 1 + fi + fi + + chown "${OWNER}:${OWNER}" ${LOBBY_CONF_PATH} + chmod 770 ${LOBBY_CONF_PATH} +} + +prepSystemd() { + if [ "$OWNER" = 'root' ]; then + printf 'Downloading Lobby systemd service unit file to %s\n' "$LOBBY_ROOT_SERVICE_PATH" + if [ ! -f $LOBBY_ROOT_SERVICE_PATH ]; then + if ! download $LOBBY_ROOT_SERVICE_PATH $LOBBY_SERVICE_URL; then + printf '\n' + printf 'Failed to download the Lobby systemd service unit file from %s\n' "$LOBBY_SERVICE_URL" + failPrint + exit 1 + fi + + sed -i "s/{{ username }}/${OWNER}/" $LOBBY_ROOT_SERVICE_PATH + fi + else + LOBBY_USER_SERVICE_DIR="/home/${OWNER}/.config/systemd/user" + LOBBY_USER_SERVICE_PATH="${LOBBY_USER_SERVICE_DIR}/lobby.service" + mkdir -p "$LOBBY_USER_SERVICE_DIR" > /dev/null 2>&1 + chown "${ONWER}:${OWNER}" "$LOBBY_USER_SERVICE_DIR" + chmod 770 "$LOBBY_USER_SERVICE_DIR" + + printf 'Downloading Lobby systemd service unit file to %s\n' "${LOBBY_USER_SERVICE_PATH}" + if [ ! -f "$LOBBY_USER_SERVICE_PATH" ]; then + if ! download "$LOBBY_USER_SERVICE_PATH" "$LOBBY_SERVICE_URL"; then + printf '\n' + printf 'Failed to download the Lobby systemd service unit file from %s\n' "$LOBBY_SERVICE_URL" + failPrint + exit 1 + fi + + sed -i '/{{ username }}/d' "$LOBBY_USER_SERVICE_PATH" + fi + fi +} + +outro() { + printf '\n' + printf '%bLobby was successfully installed%b\n' "$GREEN" "$NC" + printf '\n' + + if [ "$OWNER" = 'root' ]; then + printf 'So that the Lobby systemd service becomes available, you need to reload the systemd daemon with:\n' + printf ' systemctl daemon-reload\n' + printf '\n' + printf 'The Lobby service can be started with:\n' + printf ' systemctl start lobby\n' + printf '\n' + printf 'To start Lobby service always at system boot:\n' + printf ' systemctl enable lobby\n' + printf '\n' + printf 'The Lobby service can be stopped with:\n' + printf ' systemctl stop lobby\n' + printf '\n' + printf 'The Lobby config can be updated while Lobby is running with:\n' + printf ' systemctl reload lobby\n' + printf '\n' + printf 'The Lobby service status can be checked with:\n' + printf ' systemctl status lobby\n' + else + printf 'So that the Lobby systemd service becomes available, you need to reload the systemd daemon with:\n' + printf ' systemctl --user daemon-reload\n' + printf '\n' + printf 'The Lobby service can be started with:\n' + printf ' systemctl --user start lobby\n' + printf '\n' + printf 'To start Lobby service always at system boot:\n' + printf ' systemctl --user enable lobby\n' + printf '\n' + printf 'The Lobby service can be stopped with:\n' + printf ' systemctl --user stop lobby\n' + printf '\n' + printf 'The Lobby config can be updated while Lobby is running with:\n' + printf ' systemctl --user reload lobby\n' + printf '\n' + printf 'The Lobby service status can be checked with:\n' + printf ' systemctl --user status lobby\n' + fi + printf '\n' +} + +intro + +parseArgs "$1" + +checkDeps + +checkArch + +downloadLobbyBin + +prepConfig + +prepSystemd + +outro diff --git a/scripts/uninstallLobby.sh b/scripts/uninstallLobby.sh new file mode 100644 index 0000000..2273f9c --- /dev/null +++ b/scripts/uninstallLobby.sh @@ -0,0 +1,50 @@ +#!/bin/sh +LOBBY_BIN_DIR='/usr/local/bin' +LOBBY_BIN_NAME='lobby' +LOBBY_BIN_PATH=${LOBBY_BIN_DIR}/${LOBBY_BIN_NAME} +LOBBY_CONF_DIR='/etc/lobby' +LOBBY_ROOT_SERVICE_PATH='/etc/systemd/system/lobby.service' + +intro() { + echo Uninstalling Lobby +} + +deleteBin() { + if [ -f "$LOBBY_BIN_PATH" ]; then + rm -rf "$LOBBY_BIN_PATH" + fi +} + +deleteConf() { + if [ -d "$LOBBY_CONF_DIR" ]; then + rm -rf "$LOBBY_CONF_DIR" + fi +} + +deleteRootSysdSvs() { + if [ -f "$LOBBY_ROOT_SERVICE_PATH" ]; then + rm -rf "$LOBBY_ROOT_SERVICE_PATH" + fi +} + +deleteUserSysdSvs() { + find /home/*/ -type f -path '*/.config/systemd/user/lobby.service' -exec rm {} \; +} + +outro() { + echo + echo Uninstall completed + echo +} + +intro + +deleteBin + +deleteConf + +deleteRootSysdSvs + +deleteUserSysdSvs + +outro diff --git a/target.go b/target.go new file mode 100644 index 0000000..c8a0235 --- /dev/null +++ b/target.go @@ -0,0 +1,16 @@ +package main + +import ( + "github.com/google/nftables" +) + +// A target declares the packet destination as it arrives to the load balancer +type target struct { + name string + protocol lbProto + ip string + port uint16 + upstreamGroup *upstreamGroup + nftRuleInit bool + nftPrerRule []*nftables.Rule +} diff --git a/testEngine.go b/testEngine.go new file mode 100644 index 0000000..29eddaa --- /dev/null +++ b/testEngine.go @@ -0,0 +1,65 @@ +package main + +import ( + "net" +) + +// supported lb engine protocols and distribution modes +var tlbSuppCapabilities = map[lbProto]map[distMode]bool{ + lbProtoTcp: { + distModeRR: true, + }, +} + +type testLb struct { + failDependenciesCheck bool + failPermissionsCheck bool + failStart bool +} + +func (tlb *testLb) setResults(failDependenciesCheck, failPermissionsCheck, failStart bool) { + tlb.failDependenciesCheck = failDependenciesCheck + tlb.failPermissionsCheck = failPermissionsCheck + tlb.failStart = failStart +} + +func (tlb *testLb) checkDependencies() error { + if tlb.failDependenciesCheck { + return errCheckDep + } + return nil +} + +func (tlb *testLb) checkPermissions() error { + if tlb.failPermissionsCheck { + return errCheckPerm + } + return nil +} + +func (tlb *testLb) getCapabilities() map[lbProto]map[distMode]bool { + return tlbSuppCapabilities +} + +func (tlb *testLb) start(l *lb) error { + if tlb.failStart { + return errLbEngineStart + } + return nil +} + +func (tlb *testLb) stop() error { + return nil +} + +func (tlb *testLb) reconfig(l *lb) error { + return nil +} + +func (tlb *testLb) updateTarget(t *target) error { + return nil +} + +func (tlb *testLb) updateUpstream(u *upstream, auip *[]net.IP) error { + return nil +} diff --git a/upstream.go b/upstream.go new file mode 100644 index 0000000..8604273 --- /dev/null +++ b/upstream.go @@ -0,0 +1,156 @@ +package main + +import ( + "errors" + "fmt" + "net" + "time" + + "github.com/google/nftables" +) + +type ( + hcProto byte // healtcheck protocol + ugFoMode byte // upstream group failover mode +) + +const ( + hcProtoUnknown hcProto = iota // undefined + hcProtoTcp // tcp + hcProtoUdp // udp + hcProtoSctp // sctp + hcProtoHttp // http + hcProtoGrpc // grpc +) + +const numUgFoModes = 5 // amount of ugFoMode's + +const ( + ugFoModeUnknown ugFoMode = iota + ugFoModeInactive + ugFoModeActive1 + ugFoModeActive2 + ugFoModeDown +) + +// upstream errors +var ( + errHcp = errors.New( + "healtcheck protocol not found", + ) + errUgFM = errors.New( + "error providing next upstream failover mode", + ) +) + +func getHcProto(hcp string) (hcProto, error) { + switch hcp { + case "tcp": + return hcProtoTcp, nil + case "udp": + return hcProtoUdp, nil + case "sctp": + return hcProtoSctp, nil + case "http": + return hcProtoHttp, nil + case "grpc": + return hcProtoGrpc, nil + } + + return hcProtoUnknown, fmt.Errorf("'%s' '%w'", hcp, errHcp) +} + +func (hcp hcProto) String() string { + switch hcp { + case hcProtoTcp: + return "tcp" + case hcProtoUdp: + return "udp" + case hcProtoSctp: + return "sctp" + case hcProtoHttp: + return "http" + case hcProtoGrpc: + return "grpc" + } + return "unknown" +} + +type upstreamDns struct { + addresses []string // DNS addresses to be used to resolve the upstream host domain name + confTtl uint32 // user configured DNS TTL to overwrite DNS resolved TTL + ttl uint32 // DNS TTL to be used. confTtl will be used if set. Otherwise, the DNS resolved TTL + chDcStop chan struct{} // channel to listen to upstream dns stop requests + ticker *time.Ticker // DNS check timer. Always set to upstreamDns.ttl +} + +type healthCheck struct { + active bool // healthcheck active or inactive + protocol hcProto // healthcheck protocol + port uint16 // healtcheck port + checkInterval uint16 // healtcheck check interval in seconds + timeout uint8 // healthcheck check timeout + countConfig uint8 // healthcheck configured consecutive successful checks required to become available + count uint8 // healthcheck variable used to count progress of consecutive successful checks + chHcStop chan struct{} // channel to listen to healthcheck stop requests + ticker *time.Ticker // healtcheck timer +} + +// An upstream is a host where the traffic can be distributed to +type upstream struct { + name string // upstream name + protocol lbProto // upstream layer 4 protocol + host string // upstream host. It can be an IP address or a domain name + port uint16 // upstream port + dns upstreamDns // upstream DNS. used to resolve upstream host if a domain name + address net.IP // upstream IP address. It is either the IP address from upstream host or the resolved upstream host domain name + available bool // upstream state. available or unavailable + healthCheck healthCheck // upstream healtcheck configuration +} + +// returns the ugFoMode ID +func (ugFM ugFoMode) getId() string { + switch ugFM { + case ugFoModeUnknown: + return "0" + case ugFoModeInactive: + return "1" + case ugFoModeActive1: + return "2" + case ugFoModeActive2: + return "3" + case ugFoModeDown: + return "4" + } + + return "0" +} + +// returns the next ugFoMode +func (ugFM ugFoMode) nextMode() (ugFoMode, error) { + switch ugFM { + case ugFoModeInactive: + return ugFoModeActive1, nil + case ugFoModeActive1: + return ugFoModeActive2, nil + case ugFoModeActive2: + return ugFoModeActive1, nil + case ugFoModeDown: + return ugFoModeActive1, nil + } + + return ugFoModeUnknown, fmt.Errorf("%w", errUgFM) +} + +// An upstream group is a group of upstreams serving the same target +type upstreamGroup struct { + name string + distMode distMode + upstreams []*upstream + failoverMode ugFoMode + previousFailoverMode ugFoMode + nftUgChain []*nftables.Chain + nftUgSet []*nftables.Set + nftUgChainRule []*nftables.Rule + nftCounter *nftables.CounterObj +} diff --git a/upstream_test.go b/upstream_test.go new file mode 100644 index 0000000..f54f44f --- /dev/null +++ b/upstream_test.go @@ -0,0 +1,110 @@ +package main + +import ( + "errors" + "testing" +) + +func TestGetHcProto(t *testing.T) { + testCases := []struct { + input string + err error + result hcProto + }{ + {input: "tcp", err: nil, result: hcProtoTcp}, + {input: "udp", err: nil, result: hcProtoUdp}, + {input: "sctp", err: nil, result: hcProtoSctp}, + {input: "http", err: nil, result: hcProtoHttp}, + {input: "grpc", err: nil, result: hcProtoGrpc}, + {input: "misteak", err: errHcp, result: hcProtoUnknown}, + } + + for _, tc := range testCases { + t.Run(tc.input, func(t *testing.T) { + hcp, err := getHcProto(tc.input) + if !errors.Is(err, tc.err) { + t.Errorf("%s: expected %v, but got %v", tc.input, tc.err, err) + } + if hcp != tc.result { + t.Errorf("%s: expected %v, but got %v", tc.input, tc.result, hcp) + } + }) + } +} + +func TestHcPString(t *testing.T) { + testCases := []struct { + input hcProto + result string + }{ + {input: hcProtoTcp, result: "tcp"}, + {input: hcProtoUdp, result: "udp"}, + {input: hcProtoSctp, result: "sctp"}, + {input: hcProtoHttp, result: "http"}, + {input: hcProtoGrpc, result: "grpc"}, + {input: hcProtoUnknown, result: "unknown"}, + } + + for _, tc := range testCases { + t.Run(tc.result, func(t *testing.T) { + r := tc.input.String() + if r != tc.result { + t.Errorf("%s: expected %v, but got %v", tc.result, tc.result, r) + } + }) + } +} +func TestGetId(t *testing.T) { + testCases := []struct { + input ugFoMode + result string + }{ + {input: ugFoModeUnknown, result: "0"}, + {input: ugFoModeInactive, result: "1"}, + {input: ugFoModeActive1, result: "2"}, + {input: ugFoModeActive2, result: "3"}, + {input: ugFoModeDown, result: "4"}, + {input: 9, result: "0"}, + } + + for _, tc := range testCases { + t.Run(tc.result, func(t *testing.T) { + ugFM := tc.input.getId() + if ugFM != tc.result { + t.Errorf("%v: expected %v, but got %v", tc.result, tc.input, ugFM) + } + }) + } +} + +func TestNextMode(t *testing.T) { + testCases := []struct { + input ugFoMode + err error + result ugFoMode + }{ + {input: ugFoModeUnknown, err: errUgFM, result: ugFoModeUnknown}, + {input: ugFoModeInactive, err: nil, result: ugFoModeActive1}, + {input: ugFoModeActive1, err: nil, result: ugFoModeActive2}, + {input: ugFoModeActive2, err: nil, result: ugFoModeActive1}, + {input: ugFoModeDown, err: nil, result: ugFoModeActive1}, + {input: 9, err: errUgFM, result: ugFoModeUnknown}, + } + + for _, tc := range testCases { + t.Run(tc.input.getId(), func(t *testing.T) { + ugFM, err := tc.input.nextMode() + if ugFM != tc.result { + t.Errorf( + "%v: expected %v, but got %v", + tc.input.getId(), + tc.input.getId(), + ugFM.getId(), + ) + } + if !errors.Is(err, tc.err) { + t.Errorf("%v: expected %v, but got %v", tc.input.getId(), tc.err, err) + } + }) + } +}