├── cmd └── rgap │ ├── main.go │ ├── version.go │ ├── genpsk.go │ ├── listener.go │ ├── root.go │ └── agent.go ├── output ├── noop.go ├── output.go ├── eventlog.go ├── log.go ├── hostsfile.go ├── command.go └── dns.go ├── Dockerfile ├── .gitignore ├── .github ├── stale.yml └── workflows │ ├── build.yml │ └── docker-ci.yml ├── iface └── iface.go ├── go.mod ├── config └── config.go ├── LICENSE ├── protocol ├── announcement_test.go └── announcement.go ├── psk └── psk.go ├── listener ├── udpsource.go ├── listener.go └── group.go ├── util ├── util.go └── hintdialer.go ├── agent └── agent.go ├── go.sum ├── Makefile └── README.md /cmd/rgap/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | const ( 4 | progName = "rgap" 5 | ) 6 | 7 | var ( 8 | version = "undefined" 9 | ) 10 | 11 | func main() { 12 | Execute() 13 | } 14 | -------------------------------------------------------------------------------- /output/noop.go: -------------------------------------------------------------------------------- 1 | package output 2 | 3 | import "log" 4 | 5 | type NoOp struct{} 6 | 7 | func NewNoOp() NoOp { 8 | return NoOp{} 9 | } 10 | 11 | func (_ NoOp) Start() error { 12 | log.Println("new NoOp output started.") 13 | return nil 14 | } 15 | 16 | func (_ NoOp) Stop() error { 17 | log.Println("NoOp output stopped.") 18 | return nil 19 | } 20 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM --platform=$BUILDPLATFORM golang AS build 2 | 3 | ARG GIT_DESC=undefined 4 | 5 | WORKDIR /go/src/github.com/SenseUnit/rgap 6 | COPY . . 7 | ARG TARGETOS TARGETARCH 8 | RUN GOOS=$TARGETOS GOARCH=$TARGETARCH CGO_ENABLED=0 go build -a -tags netgo -ldflags '-s -w -extldflags "-static" -X main.version='"$GIT_DESC" ./cmd/rgap 9 | 10 | FROM scratch 11 | COPY --from=build /go/src/github.com/SenseUnit/rgap/rgap / 12 | USER 9999:9999 13 | ENTRYPOINT ["/rgap"] 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # If you prefer the allow list template instead of the deny list, see community template: 2 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore 3 | # 4 | # Binaries for programs and plugins 5 | *.exe 6 | *.exe~ 7 | *.dll 8 | *.so 9 | *.dylib 10 | 11 | # Test binary, built with `go test -c` 12 | *.test 13 | 14 | # Output of the go coverage tool, specifically when used with LiteIDE 15 | *.out 16 | 17 | # Dependency directories (remove the comment below to include it) 18 | # vendor/ 19 | 20 | # Go workspace file 21 | go.work 22 | 23 | bin/ 24 | 25 | rgap.yaml 26 | hosts 27 | -------------------------------------------------------------------------------- /.github/stale.yml: -------------------------------------------------------------------------------- 1 | # Number of days of inactivity before an issue becomes stale 2 | daysUntilStale: 60 3 | # Number of days of inactivity before a stale issue is closed 4 | daysUntilClose: 7 5 | # Issues with these labels will never be considered stale 6 | exemptLabels: 7 | - pinned 8 | - security 9 | # Label to use when marking an issue as stale 10 | staleLabel: wontfix 11 | # Comment to post when marking an issue as stale. Set to `false` to disable 12 | markComment: > 13 | This issue has been automatically marked as stale because it has not had 14 | recent activity. It will be closed if no further activity occurs. Thank you 15 | for your contributions. 16 | # Comment to post when closing a stale issue. Set to `false` to disable 17 | closeComment: false 18 | -------------------------------------------------------------------------------- /iface/iface.go: -------------------------------------------------------------------------------- 1 | package iface 2 | 3 | import ( 4 | "context" 5 | "net" 6 | "net/netip" 7 | "time" 8 | ) 9 | 10 | type Dialer interface { 11 | DialContext(ctx context.Context, network, address string) (net.Conn, error) 12 | } 13 | 14 | type GroupEventCallback = func(group uint64, item GroupItem) 15 | 16 | type GroupBridge interface { 17 | Groups() []uint64 18 | ListGroup(uint64) []GroupItem 19 | GroupReady(uint64) bool 20 | GroupReadinessBarrier(uint64) <-chan struct{} 21 | OnJoin(uint64, GroupEventCallback) func() 22 | OnLeave(uint64, GroupEventCallback) func() 23 | } 24 | 25 | type GroupItem interface { 26 | Address() netip.Addr 27 | ExpiresAt() time.Time 28 | } 29 | 30 | type StartStopper interface { 31 | Start() error 32 | Stop() error 33 | } 34 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/SenseUnit/rgap 2 | 3 | go 1.21.4 4 | toolchain go1.24.1 5 | 6 | require ( 7 | github.com/hashicorp/go-multierror v1.1.1 8 | github.com/jellydator/ttlcache/v3 v3.2.1-0.20240611075242-62c37338e6b2 9 | github.com/miekg/dns v1.1.58 10 | github.com/natefinch/atomic v1.0.1 11 | github.com/spf13/cobra v1.8.0 12 | golang.org/x/exp v0.0.0-20240119083558-1b970713d09a 13 | gopkg.in/yaml.v3 v3.0.1 14 | pgregory.net/rand v1.0.2 15 | ) 16 | 17 | require ( 18 | github.com/hashicorp/errwrap v1.0.0 // indirect 19 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 20 | github.com/spf13/pflag v1.0.5 // indirect 21 | golang.org/x/mod v0.14.0 // indirect 22 | golang.org/x/net v0.38.0 // indirect 23 | golang.org/x/sync v0.7.0 // indirect 24 | golang.org/x/sys v0.31.0 // indirect 25 | golang.org/x/tools v0.17.0 // indirect 26 | ) 27 | -------------------------------------------------------------------------------- /config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "net/netip" 5 | "time" 6 | 7 | "gopkg.in/yaml.v3" 8 | 9 | "github.com/SenseUnit/rgap/iface" 10 | "github.com/SenseUnit/rgap/psk" 11 | ) 12 | 13 | type AgentConfig struct { 14 | Group uint64 15 | Address netip.Addr 16 | Key psk.PSK 17 | Interval time.Duration 18 | Destinations []string 19 | Dialer iface.Dialer 20 | } 21 | 22 | type GroupConfig struct { 23 | ID uint64 24 | PSK *psk.PSK 25 | Expire time.Duration 26 | ClockSkew time.Duration `yaml:"clock_skew"` 27 | ReadinessDelay time.Duration `yaml:"readiness_delay"` 28 | } 29 | 30 | type OutputConfig struct { 31 | Kind string 32 | Spec yaml.Node 33 | } 34 | 35 | type ListenerConfig struct { 36 | Listen []string 37 | Groups []GroupConfig 38 | Outputs []OutputConfig 39 | } 40 | -------------------------------------------------------------------------------- /cmd/rgap/version.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/spf13/cobra" 7 | ) 8 | 9 | // versionCmd represents the version command 10 | var versionCmd = &cobra.Command{ 11 | Use: "version", 12 | Short: "Print program version and exit", 13 | Run: func(cmd *cobra.Command, args []string) { 14 | fmt.Println(version) 15 | }, 16 | } 17 | 18 | func init() { 19 | rootCmd.AddCommand(versionCmd) 20 | 21 | // Here you will define your flags and configuration settings. 22 | 23 | // Cobra supports Persistent Flags which will work for this command 24 | // and all subcommands, e.g.: 25 | // versionCmd.PersistentFlags().String("foo", "", "A help for foo") 26 | 27 | // Cobra supports local flags which will only run when this command 28 | // is called directly, e.g.: 29 | // versionCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle") 30 | } 31 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | permissions: 12 | contents: write 13 | 14 | steps: 15 | - 16 | name: Checkout 17 | uses: actions/checkout@v4 18 | with: 19 | fetch-depth: 0 20 | - 21 | name: Setup Go 22 | uses: actions/setup-go@v4 23 | with: 24 | go-version: 'stable' 25 | - 26 | name: Read tag 27 | id: tag 28 | run: echo "tag=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT 29 | - 30 | name: Build 31 | run: >- 32 | make -j $(nproc) all 33 | VERSION=${{steps.tag.outputs.tag}} 34 | - 35 | name: Release 36 | uses: softprops/action-gh-release@v1 37 | with: 38 | files: bin/* 39 | fail_on_unmatched_files: true 40 | generate_release_notes: true 41 | -------------------------------------------------------------------------------- /cmd/rgap/genpsk.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/SenseUnit/rgap/psk" 7 | "github.com/spf13/cobra" 8 | ) 9 | 10 | // genpskCmd represents the genpsk command 11 | var genpskCmd = &cobra.Command{ 12 | Use: "genpsk", 13 | Short: "Generate and output hex-encoded pre-shared key", 14 | RunE: func(cmd *cobra.Command, args []string) error { 15 | psk, err := psk.GeneratePSK() 16 | if err != nil { 17 | return fmt.Errorf("PSK generation failed: %w", err) 18 | } 19 | fmt.Println(psk.String()) 20 | return nil 21 | }, 22 | } 23 | 24 | func init() { 25 | rootCmd.AddCommand(genpskCmd) 26 | 27 | // Here you will define your flags and configuration settings. 28 | 29 | // Cobra supports Persistent Flags which will work for this command 30 | // and all subcommands, e.g.: 31 | // genpskCmd.PersistentFlags().String("foo", "", "A help for foo") 32 | 33 | // Cobra supports local flags which will only run when this command 34 | // is called directly, e.g.: 35 | // genpskCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle") 36 | } 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Snawoot 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /output/output.go: -------------------------------------------------------------------------------- 1 | package output 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/SenseUnit/rgap/config" 7 | "github.com/SenseUnit/rgap/iface" 8 | ) 9 | 10 | type OutputCtor func(*config.OutputConfig, iface.GroupBridge) (iface.StartStopper, error) 11 | 12 | var outputVCMap = map[string]OutputCtor{ 13 | "noop": func(_ *config.OutputConfig, _ iface.GroupBridge) (iface.StartStopper, error) { 14 | return NewNoOp(), nil 15 | }, 16 | "log": func(cfg *config.OutputConfig, bridge iface.GroupBridge) (iface.StartStopper, error) { 17 | return NewLog(cfg, bridge) 18 | }, 19 | "hostsfile": func(cfg *config.OutputConfig, bridge iface.GroupBridge) (iface.StartStopper, error) { 20 | return NewHostsFile(cfg, bridge) 21 | }, 22 | "dns": func(cfg *config.OutputConfig, bridge iface.GroupBridge) (iface.StartStopper, error) { 23 | return NewDNSServer(cfg, bridge) 24 | }, 25 | "eventlog": func(cfg *config.OutputConfig, bridge iface.GroupBridge) (iface.StartStopper, error) { 26 | return NewEventLog(cfg, bridge) 27 | }, 28 | "command": func(cfg *config.OutputConfig, bridge iface.GroupBridge) (iface.StartStopper, error) { 29 | return NewCommand(cfg, bridge) 30 | }, 31 | } 32 | 33 | func OutputFromConfig(cfg *config.OutputConfig, bridge iface.GroupBridge) (iface.StartStopper, error) { 34 | ctor, ok := outputVCMap[cfg.Kind] 35 | if !ok { 36 | return nil, errors.New("unknown kind of output") 37 | } 38 | return ctor(cfg, bridge) 39 | } 40 | -------------------------------------------------------------------------------- /protocol/announcement_test.go: -------------------------------------------------------------------------------- 1 | package protocol 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/SenseUnit/rgap/psk" 8 | "github.com/SenseUnit/rgap/util" 9 | ) 10 | 11 | func noError(err error) { 12 | if err != nil { 13 | panic(err) 14 | } 15 | } 16 | 17 | func TestSizes(t *testing.T) { 18 | if AnnouncementSize != 66 { 19 | t.Errorf("announcement size seem to be incorrect: %d != 66", AnnouncementSize) 20 | } 21 | if AnnouncementDataSize != 34 { 22 | t.Errorf("announcement size seem to be incorrect: %d != 34", AnnouncementDataSize) 23 | } 24 | } 25 | 26 | func TestMarshalUnmarshal(t *testing.T) { 27 | 28 | key := util.Must(psk.GeneratePSK()) 29 | 30 | msg := Announcement{ 31 | Data: AnnouncementData{ 32 | Version: 0x0100, 33 | RedundancyID: 12345678901234567890, 34 | Timestamp: time.Now().UnixMicro(), 35 | AnnouncedAddress: [16]byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 127, 0, 0, 1}, // Replace with actual IP address 36 | }, 37 | } 38 | 39 | msg.Signature = util.Must(msg.Data.CalculateSignature(key)) 40 | pkt := util.Must(msg.MarshalBinary()) 41 | 42 | // Display the announcement message 43 | t.Log(msg.String()) 44 | t.Logf("%x", pkt) 45 | 46 | msg1 := Announcement{} 47 | noError(msg1.UnmarshalBinary(pkt)) 48 | if res := util.Must(msg1.CheckSignature(key)); !res { 49 | t.Error("signature verification failed!") 50 | return 51 | } 52 | if msg1 != msg { 53 | t.Error("message is not equal to original after serialization/deserialization round trip") 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /psk/psk.go: -------------------------------------------------------------------------------- 1 | package psk 2 | 3 | import ( 4 | "crypto/rand" 5 | "encoding/hex" 6 | "fmt" 7 | 8 | "gopkg.in/yaml.v3" 9 | ) 10 | 11 | const ( 12 | PSKSize = 32 13 | ) 14 | 15 | type PSK [PSKSize]byte 16 | 17 | func (psk *PSK) AsSlice() []byte { 18 | return psk[:] 19 | } 20 | 21 | func (psk *PSK) AsHexString() string { 22 | return hex.EncodeToString(psk.AsSlice()) 23 | } 24 | 25 | func (psk *PSK) FromHexString(s string) error { 26 | b, err := hex.DecodeString(s) 27 | if err != nil { 28 | return fmt.Errorf("PSK hex decoding failed: %w", err) 29 | } 30 | if len(b) != PSKSize { 31 | return fmt.Errorf("incorrect PSK length. Expected %d, got %d", PSKSize, len(b)) 32 | } 33 | copy(psk.AsSlice(), b) 34 | return nil 35 | } 36 | 37 | func (psk *PSK) String() string { 38 | return psk.AsHexString() 39 | } 40 | 41 | func (psk *PSK) UnmarshalYAML(value *yaml.Node) error { 42 | var hexval string 43 | if err := value.Decode(&hexval); err != nil { 44 | return fmt.Errorf("PSK unmarshaler unable to retrieve hex string from given node: %w", err) 45 | } 46 | if err := psk.FromHexString(hexval); err != nil { 47 | return fmt.Errorf("PSK unmarshaller can't set value from hex string: %w", err) 48 | } 49 | return nil 50 | } 51 | 52 | func (psk *PSK) MarshalYAML() (interface{}, error) { 53 | return psk.AsHexString(), nil 54 | } 55 | 56 | func GeneratePSK() (PSK, error) { 57 | var psk PSK 58 | if _, err := rand.Read(psk.AsSlice()); err != nil { 59 | return psk, fmt.Errorf("unable to generate random bytes for PSK: %w", err) 60 | } 61 | return psk, nil 62 | } 63 | -------------------------------------------------------------------------------- /output/eventlog.go: -------------------------------------------------------------------------------- 1 | package output 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | 7 | "github.com/SenseUnit/rgap/config" 8 | "github.com/SenseUnit/rgap/iface" 9 | "github.com/SenseUnit/rgap/util" 10 | ) 11 | 12 | type EventLogConfig struct { 13 | Groups []uint64 `yaml:"only_groups"` 14 | } 15 | 16 | type EventLog struct { 17 | bridge iface.GroupBridge 18 | groups []uint64 19 | unsubFns []func() 20 | } 21 | 22 | func NewEventLog(cfg *config.OutputConfig, bridge iface.GroupBridge) (*EventLog, error) { 23 | var lc EventLogConfig 24 | if err := util.CheckedUnmarshal(&cfg.Spec, &lc); err != nil { 25 | return nil, fmt.Errorf("cannot unmarshal log output config: %w", err) 26 | } 27 | return &EventLog{ 28 | bridge: bridge, 29 | groups: lc.Groups, 30 | }, nil 31 | } 32 | 33 | func (o *EventLog) Start() error { 34 | groups := o.groups 35 | if groups == nil { 36 | groups = o.bridge.Groups() 37 | } 38 | o.unsubFns = make([]func(), 0, len(groups)*2) 39 | for _, group := range groups { 40 | o.unsubFns = append(o.unsubFns, 41 | o.bridge.OnJoin(group, func(group uint64, item iface.GroupItem) { 42 | log.Printf("host %s has joined group %d", item.Address().Unmap().String(), group) 43 | }), 44 | o.bridge.OnLeave(group, func(group uint64, item iface.GroupItem) { 45 | log.Printf("host %s has left group %d", item.Address().Unmap().String(), group) 46 | }), 47 | ) 48 | } 49 | log.Println("started event log output plugin") 50 | return nil 51 | } 52 | 53 | func (o *EventLog) Stop() error { 54 | for _, unsub := range o.unsubFns { 55 | unsub() 56 | } 57 | log.Println("stopped event log output plugin") 58 | return nil 59 | } 60 | -------------------------------------------------------------------------------- /cmd/rgap/listener.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/spf13/cobra" 8 | "gopkg.in/yaml.v3" 9 | 10 | "github.com/SenseUnit/rgap/config" 11 | "github.com/SenseUnit/rgap/listener" 12 | ) 13 | 14 | var ( 15 | configPath string 16 | ) 17 | 18 | // listenerCmd represents the listener command 19 | var listenerCmd = &cobra.Command{ 20 | Use: "listener", 21 | Short: "Starts listener accepting and processing announcements", 22 | RunE: func(cmd *cobra.Command, args []string) error { 23 | var cfg config.ListenerConfig 24 | cfgF, err := os.Open(configPath) 25 | if err != nil { 26 | return fmt.Errorf("unable to read configuration file: %w", err) 27 | } 28 | defer cfgF.Close() 29 | dec := yaml.NewDecoder(cfgF) 30 | dec.KnownFields(true) 31 | if err := dec.Decode(&cfg); err != nil { 32 | return fmt.Errorf("unable to decode configuration file: %w", err) 33 | } 34 | listener, err := listener.NewListener(&cfg) 35 | if err != nil { 36 | return fmt.Errorf("can't initialize listener: %w", err) 37 | } 38 | return listener.Run(cmd.Context()) 39 | }, 40 | } 41 | 42 | func init() { 43 | rootCmd.AddCommand(listenerCmd) 44 | 45 | // Here you will define your flags and configuration settings. 46 | 47 | // Cobra supports Persistent Flags which will work for this command 48 | // and all subcommands, e.g.: 49 | // listenerCmd.PersistentFlags().String("foo", "", "A help for foo") 50 | 51 | // Cobra supports local flags which will only run when this command 52 | // is called directly, e.g.: 53 | // listenerCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle") 54 | listenerCmd.Flags().StringVarP(&configPath, "config", "c", "rgap.yaml", "configuration file") 55 | } 56 | -------------------------------------------------------------------------------- /.github/workflows/docker-ci.yml: -------------------------------------------------------------------------------- 1 | name: docker-ci 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*.*.*' 7 | 8 | env: 9 | REGISTRY: ghcr.io 10 | IMAGE_NAME: ${{ github.repository }} 11 | 12 | jobs: 13 | build-and-push-image: 14 | runs-on: ubuntu-latest 15 | 16 | permissions: 17 | contents: read 18 | packages: write 19 | 20 | steps: 21 | - 22 | name: Checkout 23 | uses: actions/checkout@v4 24 | with: 25 | fetch-depth: 0 26 | - 27 | name: Find Git Tag 28 | id: tagger 29 | uses: jimschubert/query-tag-action@v2 30 | with: 31 | include: 'v*' 32 | exclude: '*-rc*' 33 | commit-ish: 'HEAD' 34 | skip-unshallow: 'true' 35 | abbrev: 7 36 | - name: Docker meta 37 | id: meta 38 | uses: docker/metadata-action@v5 39 | with: 40 | # list of Docker images to use as base name for tags 41 | images: | 42 | ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 43 | # generate Docker tags based on the following events/attributes 44 | tags: | 45 | type=semver,pattern={{version}} 46 | type=semver,pattern={{major}}.{{minor}} 47 | type=semver,pattern={{major}} 48 | type=sha 49 | - 50 | name: Set up QEMU 51 | uses: docker/setup-qemu-action@v3 52 | - 53 | name: Set up Docker Buildx 54 | uses: docker/setup-buildx-action@v3 55 | - 56 | name: Login to DockerHub 57 | uses: docker/login-action@v3 58 | with: 59 | registry: ${{ env.REGISTRY }} 60 | username: ${{ github.actor }} 61 | password: ${{ secrets.GITHUB_TOKEN }} 62 | - 63 | name: Build and push 64 | id: docker_build 65 | uses: docker/build-push-action@v5 66 | with: 67 | context: . 68 | platforms: linux/amd64,linux/arm64,linux/386,linux/arm/v7 69 | push: true 70 | tags: ${{ steps.meta.outputs.tags }} 71 | labels: ${{ steps.meta.outputs.labels }} 72 | build-args: 'GIT_DESC=${{steps.tagger.outputs.tag}}' 73 | -------------------------------------------------------------------------------- /output/log.go: -------------------------------------------------------------------------------- 1 | package output 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | "strings" 8 | "time" 9 | 10 | "github.com/SenseUnit/rgap/config" 11 | "github.com/SenseUnit/rgap/iface" 12 | "github.com/SenseUnit/rgap/util" 13 | ) 14 | 15 | type LogConfig struct { 16 | Interval time.Duration 17 | } 18 | 19 | type Log struct { 20 | bridge iface.GroupBridge 21 | interval time.Duration 22 | ctx context.Context 23 | ctxCancel func() 24 | loopDone chan struct{} 25 | } 26 | 27 | func NewLog(cfg *config.OutputConfig, bridge iface.GroupBridge) (*Log, error) { 28 | var lc LogConfig 29 | if err := util.CheckedUnmarshal(&cfg.Spec, &lc); err != nil { 30 | return nil, fmt.Errorf("cannot unmarshal log output config: %w", err) 31 | } 32 | if lc.Interval <= 0 { 33 | return nil, fmt.Errorf("incorrect log interval: %v", lc.Interval) 34 | } 35 | return &Log{ 36 | interval: lc.Interval, 37 | bridge: bridge, 38 | }, nil 39 | } 40 | 41 | func (o *Log) Start() error { 42 | ctx, cancel := context.WithCancel(context.Background()) 43 | o.ctx = ctx 44 | o.ctxCancel = cancel 45 | o.loopDone = make(chan struct{}) 46 | go o.loop() 47 | log.Println("started log output plugin") 48 | return nil 49 | } 50 | 51 | func (o *Log) Stop() error { 52 | o.ctxCancel() 53 | <-o.loopDone 54 | log.Println("stopped log output plugin") 55 | return nil 56 | } 57 | 58 | func (o *Log) loop() { 59 | defer close(o.loopDone) 60 | ticker := time.NewTicker(o.interval) 61 | defer ticker.Stop() 62 | for { 63 | select { 64 | case <-o.ctx.Done(): 65 | return 66 | case <-ticker.C: 67 | o.dump() 68 | } 69 | } 70 | } 71 | 72 | var readinessLabels = map[bool]string{ 73 | true: "READY", 74 | false: "NOT READY", 75 | } 76 | 77 | func (o *Log) dump() { 78 | var report strings.Builder 79 | fmt.Fprintln(&report, "Groups snapshot:") 80 | for _, gid := range o.bridge.Groups() { 81 | grpItems := o.bridge.ListGroup(gid) 82 | fmt.Fprintf(&report, " - Group %d (%s, %d entries):\n", gid, readinessLabels[o.bridge.GroupReady(gid)], len(grpItems)) 83 | for _, item := range grpItems { 84 | fmt.Fprintf(&report, " - %s (till %v)\n", item.Address().Unmap().String(), item.ExpiresAt()) 85 | } 86 | } 87 | log.Println(report.String()) 88 | } 89 | -------------------------------------------------------------------------------- /cmd/rgap/root.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "os" 7 | "os/signal" 8 | "strings" 9 | "syscall" 10 | 11 | "github.com/spf13/cobra" 12 | ) 13 | 14 | const ( 15 | envLogPrefix = "RGAP_LOG_PREFIX" 16 | ) 17 | 18 | var ( 19 | logPrefix logPrefixValue = newLogPrefixValue(defaultLogPrefix()) 20 | ) 21 | 22 | type logPrefixValue struct { 23 | value *string 24 | } 25 | 26 | func newLogPrefixValue(s string) logPrefixValue { 27 | return logPrefixValue{ 28 | value: &s, 29 | } 30 | } 31 | 32 | func (v *logPrefixValue) String() string { 33 | if v == nil || v.value == nil { 34 | return defaultLogPrefix() 35 | } 36 | return *v.value 37 | } 38 | 39 | func (v *logPrefixValue) Type() string { 40 | return "string" 41 | } 42 | 43 | func (v *logPrefixValue) Set(s string) error { 44 | v.value = &s 45 | return nil 46 | } 47 | 48 | func defaultLogPrefix() string { 49 | if envLogPrefixValue, ok := os.LookupEnv(envLogPrefix); ok { 50 | return envLogPrefixValue 51 | } 52 | return strings.ToUpper(progName) + ": " 53 | } 54 | 55 | // rootCmd represents the base command when called without any subcommands 56 | var rootCmd = &cobra.Command{ 57 | Use: progName, 58 | Short: "Redundancy Group Announcement Protocol", 59 | Long: `See https://gist.github.com/Snawoot/39282757e5f7db40632e5e01280b683f for more details.`, 60 | SilenceUsage: true, 61 | // Uncomment the following line if your bare application 62 | // has an action associated with it: 63 | // Run: func(cmd *cobra.Command, args []string) { }, 64 | } 65 | 66 | // Execute adds all child commands to the root command and sets flags appropriately. 67 | // This is called by main.main(). It only needs to happen once to the rootCmd. 68 | func Execute() { 69 | ctx, done := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) 70 | defer done() 71 | log.Default().SetFlags(log.Ldate | log.Ltime | log.Lmicroseconds | log.Lshortfile) 72 | log.Default().SetPrefix(logPrefix.String()) 73 | err := rootCmd.ExecuteContext(ctx) 74 | if err != nil { 75 | os.Exit(1) 76 | } 77 | } 78 | 79 | func init() { 80 | // Here you will define your flags and configuration settings. 81 | // Cobra supports persistent flags, which, if defined here, 82 | // will be global for your application. 83 | 84 | rootCmd.PersistentFlags().Var(&logPrefix, "log-prefix", "log prefix") 85 | 86 | // Cobra also supports local flags, which will only run 87 | // when this action is called directly. 88 | // rootCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle") 89 | } 90 | -------------------------------------------------------------------------------- /listener/udpsource.go: -------------------------------------------------------------------------------- 1 | package listener 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | "net" 8 | 9 | "github.com/SenseUnit/rgap/protocol" 10 | "github.com/SenseUnit/rgap/util" 11 | ) 12 | 13 | type UDPSource struct { 14 | address string 15 | label string 16 | callback func(string, *protocol.Announcement) 17 | ctx context.Context 18 | ctxCancel func() 19 | loopDone chan struct{} 20 | } 21 | 22 | func NewUDPSource(address string, label string, callback func(string, *protocol.Announcement)) *UDPSource { 23 | s := &UDPSource{ 24 | address: address, 25 | label: label, 26 | callback: callback, 27 | } 28 | return s 29 | } 30 | 31 | func (s *UDPSource) Start() error { 32 | ctx, cancel := context.WithCancel(context.Background()) 33 | s.ctx = ctx 34 | s.ctxCancel = cancel 35 | s.loopDone = make(chan struct{}) 36 | 37 | listenAddr, iface, err := util.SplitAndResolveAddrSpec(s.address) 38 | if err != nil { 39 | return fmt.Errorf("UDP source %s: interface resolving failed: %w", s.address, err) 40 | } 41 | 42 | udpAddr, err := net.ResolveUDPAddr("udp", listenAddr) 43 | if err != nil { 44 | return fmt.Errorf("bad UDP listen address: %w", err) 45 | } 46 | 47 | var conn *net.UDPConn 48 | 49 | if udpAddr.IP.IsMulticast() { 50 | conn, err = net.ListenMulticastUDP("udp", iface, udpAddr) 51 | if err != nil { 52 | return fmt.Errorf("UDP listen failed: %w", err) 53 | } 54 | } else { 55 | conn, err = net.ListenUDP("udp", udpAddr) 56 | if err != nil { 57 | return fmt.Errorf("UDP listen failed: %w", err) 58 | } 59 | } 60 | 61 | go func() { 62 | select { 63 | case <-ctx.Done(): 64 | case <-s.loopDone: 65 | } 66 | conn.Close() 67 | }() 68 | go s.readLoop(conn) 69 | log.Printf("Started UDP source @ %s", s.address) 70 | return nil 71 | } 72 | 73 | func (s *UDPSource) Stop() error { 74 | s.ctxCancel() 75 | <-s.loopDone 76 | log.Printf("Stopped UDP source @ %s", s.address) 77 | return nil 78 | } 79 | 80 | func (s *UDPSource) readLoop(conn *net.UDPConn) { 81 | defer close(s.loopDone) 82 | buf := make([]byte, 4096) 83 | for s.ctx.Err() == nil { 84 | n, _, err := conn.ReadFromUDP(buf) 85 | if err != nil { 86 | if s.ctx.Err() != nil { 87 | return 88 | } 89 | log.Printf("source %s: UDP read error: %v", s.label, err) 90 | continue 91 | } 92 | if n != protocol.AnnouncementSize { 93 | continue 94 | } 95 | ann := new(protocol.Announcement) 96 | if err := ann.UnmarshalBinary(buf[:n]); err != nil { 97 | log.Printf("announce unmarshaling failed: %v", err) 98 | } 99 | s.callback(s.label, ann) 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /protocol/announcement.go: -------------------------------------------------------------------------------- 1 | package protocol 2 | 3 | import ( 4 | "bytes" 5 | "crypto/hmac" 6 | "crypto/sha256" 7 | "encoding/binary" 8 | "fmt" 9 | "net" 10 | 11 | "github.com/SenseUnit/rgap/psk" 12 | ) 13 | 14 | const ( 15 | SignaturePrefix = "RGAP announce" 16 | SignatureSize = 32 17 | V1 uint16 = 0x0100 18 | ) 19 | 20 | var SignaturePrefixBytes = []byte(SignaturePrefix) 21 | 22 | type AnnouncementData struct { 23 | Version uint16 24 | RedundancyID uint64 25 | Timestamp int64 26 | AnnouncedAddress [16]byte 27 | } 28 | 29 | var AnnouncementDataSize = binary.Size(new(AnnouncementData)) 30 | 31 | func (ad *AnnouncementData) MarshalBinary() (data []byte, err error) { 32 | buf := bytes.NewBuffer(make([]byte, 0, AnnouncementDataSize)) 33 | if err := binary.Write(buf, binary.BigEndian, ad); err != nil { 34 | return nil, fmt.Errorf("binary marshaling of announcement data failed: %w", err) 35 | } 36 | return buf.Bytes(), nil 37 | } 38 | 39 | func (ad *AnnouncementData) UnmarshalBinary(data []byte) error { 40 | buf := bytes.NewBuffer(data) 41 | if err := binary.Read(buf, binary.BigEndian, ad); err != nil { 42 | return fmt.Errorf("binary unmarshaling of announcement data failed: %w", err) 43 | } 44 | return nil 45 | } 46 | 47 | func (ad *AnnouncementData) CalculateSignature(key psk.PSK) ([SignatureSize]byte, error) { 48 | h := hmac.New(sha256.New, key.AsSlice()) 49 | h.Write([]byte(SignaturePrefixBytes)) 50 | if err := binary.Write(h, binary.BigEndian, ad); err != nil { 51 | return [SignatureSize]byte{}, fmt.Errorf("announcement data signing failed: %w", err) 52 | } 53 | var sig [SignatureSize]byte 54 | copy(sig[:], h.Sum(nil)) 55 | return sig, nil 56 | } 57 | 58 | func (a *AnnouncementData) String() string { 59 | return fmt.Sprintf("AnnouncementData", 60 | a.Version, a.RedundancyID, a.Timestamp, net.IP(a.AnnouncedAddress[:])) 61 | } 62 | 63 | type Announcement struct { 64 | Data AnnouncementData 65 | Signature [SignatureSize]byte 66 | } 67 | 68 | var AnnouncementSize = binary.Size(new(Announcement)) 69 | 70 | func (a *Announcement) MarshalBinary() (data []byte, err error) { 71 | buf := bytes.NewBuffer(make([]byte, 0, AnnouncementSize)) 72 | if err := binary.Write(buf, binary.BigEndian, a); err != nil { 73 | return nil, fmt.Errorf("binary marshaling of announcement failed: %w", err) 74 | } 75 | return buf.Bytes(), nil 76 | } 77 | 78 | func (a *Announcement) UnmarshalBinary(data []byte) error { 79 | buf := bytes.NewBuffer(data) 80 | if err := binary.Read(buf, binary.BigEndian, a); err != nil { 81 | return fmt.Errorf("binary unmarshaling of announcement failed: %w", err) 82 | } 83 | return nil 84 | } 85 | 86 | func (a *Announcement) CheckSignature(key psk.PSK) (bool, error) { 87 | sig, err := a.Data.CalculateSignature(key) 88 | if err != nil { 89 | return false, fmt.Errorf("signature verification failed: %w", err) 90 | } 91 | return hmac.Equal(sig[:], a.Signature[:]), nil 92 | } 93 | 94 | func (a *Announcement) String() string { 95 | return fmt.Sprintf("Announcement", a.Data.String(), a.Signature) 96 | } 97 | -------------------------------------------------------------------------------- /util/util.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "fmt" 7 | "log" 8 | "net" 9 | "net/netip" 10 | "strings" 11 | 12 | "golang.org/x/exp/constraints" 13 | "gopkg.in/yaml.v3" 14 | ) 15 | 16 | type IPAddr netip.Addr 17 | 18 | func (a *IPAddr) Addr() netip.Addr { 19 | return netip.Addr(*a) 20 | } 21 | 22 | func (a *IPAddr) String() string { 23 | return (*netip.Addr)(a).String() 24 | } 25 | 26 | func (a *IPAddr) MarshalYAML() (interface{}, error) { 27 | return a.String(), nil 28 | } 29 | 30 | func (a *IPAddr) UnmarshalYAML(value *yaml.Node) error { 31 | var decodedVal string 32 | if err := value.Decode(&decodedVal); err != nil { 33 | return err 34 | } 35 | parsedAddr, err := netip.ParseAddr(decodedVal) 36 | if err != nil { 37 | return err 38 | } 39 | *a = IPAddr(parsedAddr) 40 | return nil 41 | } 42 | 43 | func Must[V any](value V, err error) V { 44 | if err != nil { 45 | panic(err) 46 | } 47 | return value 48 | } 49 | 50 | func CheckedUnmarshal(doc *yaml.Node, dst interface{}) error { 51 | var buf bytes.Buffer 52 | enc := yaml.NewEncoder(&buf) 53 | if err := enc.Encode(doc); err != nil { 54 | return fmt.Errorf("unable to re-marshal node: %w", err) 55 | } 56 | if err := enc.Close(); err != nil { 57 | return fmt.Errorf("unable to re-marshal node: close failed: %w", err) 58 | } 59 | dec := yaml.NewDecoder(&buf) 60 | dec.KnownFields(true) // that's whole point of such marshaling round trip 61 | if err := dec.Decode(dst); err != nil { 62 | return fmt.Errorf("unable to unmarshal node: %w", err) 63 | } 64 | return nil 65 | } 66 | 67 | func SplitAndResolveAddrSpec(spec string) (string, *net.Interface, error) { 68 | addrSpec, ifaceSpec, found := strings.Cut(spec, "@") 69 | if !found { 70 | return addrSpec, nil, nil 71 | } 72 | iface, err := ResolveInterface(ifaceSpec) 73 | if err != nil { 74 | return addrSpec, nil, fmt.Errorf("unable to resolve interface spec %q: %w", ifaceSpec, err) 75 | } 76 | return addrSpec, iface, nil 77 | } 78 | 79 | func ResolveInterface(spec string) (*net.Interface, error) { 80 | ifaces, err := net.Interfaces() 81 | if err != nil { 82 | return nil, fmt.Errorf("unable to enumerate interfaces: %w", err) 83 | } 84 | if pfx, err := netip.ParsePrefix(spec); err == nil { 85 | // look for address 86 | for i := range ifaces { 87 | addrs, err := ifaces[i].Addrs() 88 | if err != nil { 89 | // may be a problem with some interface, 90 | // but we still probably can find the right one 91 | log.Printf("WARNING: interface %s is failing to report its addresses: %v", ifaces[i].Name, err) 92 | continue 93 | } 94 | for _, addr := range addrs { 95 | ipnet, ok := addr.(*net.IPNet) 96 | if !ok { 97 | return nil, fmt.Errorf("unexpected type returned as address interface: %T", addr) 98 | } 99 | netipAddr, ok := netip.AddrFromSlice(ipnet.IP) 100 | if !ok { 101 | return nil, fmt.Errorf("interface %v has invalid address %s", ifaces[i].Name, ipnet.IP) 102 | } 103 | netipAddr = netipAddr.Unmap() 104 | if pfx.Contains(netipAddr) { 105 | res := ifaces[i] 106 | return &res, nil 107 | } 108 | } 109 | } 110 | } else { 111 | // look for iface name 112 | for i := range ifaces { 113 | if ifaces[i].Name == spec { 114 | res := ifaces[i] 115 | return &res, nil 116 | } 117 | } 118 | } 119 | return nil, errors.New("specified interface not found") 120 | } 121 | 122 | func Max[T constraints.Ordered](x, y T) T { 123 | if x >= y { 124 | return x 125 | } 126 | return y 127 | } 128 | 129 | func Min[T constraints.Ordered](x, y T) T { 130 | if x <= y { 131 | return x 132 | } 133 | return y 134 | } 135 | -------------------------------------------------------------------------------- /listener/listener.go: -------------------------------------------------------------------------------- 1 | package listener 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | 8 | "github.com/SenseUnit/rgap/config" 9 | "github.com/SenseUnit/rgap/iface" 10 | "github.com/SenseUnit/rgap/output" 11 | "github.com/SenseUnit/rgap/protocol" 12 | ) 13 | 14 | type Listener struct { 15 | sources []iface.StartStopper 16 | groups map[uint64]*Group 17 | outputs []iface.StartStopper 18 | } 19 | 20 | func NewListener(cfg *config.ListenerConfig) (*Listener, error) { 21 | l := &Listener{ 22 | groups: make(map[uint64]*Group), 23 | } 24 | for i, gc := range cfg.Groups { 25 | g, err := GroupFromConfig(&gc) 26 | if err != nil { 27 | return nil, fmt.Errorf("unable to construct new group with index %d: %w", i, err) 28 | } 29 | l.groups[g.ID()] = g 30 | } 31 | for _, address := range cfg.Listen { 32 | src := NewUDPSource(address, address, l.announceCallback) 33 | l.sources = append(l.sources, src) 34 | } 35 | for i, oc := range cfg.Outputs { 36 | out, err := output.OutputFromConfig(&oc, l) 37 | if err != nil { 38 | return nil, fmt.Errorf("unable to construct new output with index %d: %w", i, err) 39 | } 40 | l.outputs = append(l.outputs, out) 41 | } 42 | return l, nil 43 | } 44 | 45 | func (l *Listener) announceCallback(label string, ann *protocol.Announcement) { 46 | group, ok := l.groups[ann.Data.RedundancyID] 47 | if !ok { 48 | return 49 | } 50 | if err := group.Ingest(ann); err != nil { 51 | log.Printf("Group %d ingestion error: %v", group.ID(), err) 52 | } 53 | } 54 | 55 | func (l *Listener) Run(ctx context.Context) error { 56 | var primeStack []iface.StartStopper 57 | defer func() { 58 | for i := len(primeStack) - 1; i >= 0; i-- { 59 | if err := primeStack[i].Stop(); err != nil { 60 | log.Printf("shutdown error: %v", err) 61 | } 62 | } 63 | }() 64 | for _, group := range l.groups { 65 | if err := group.Start(); err != nil { 66 | return fmt.Errorf("startup error: %w", err) 67 | } 68 | primeStack = append(primeStack, group) 69 | } 70 | for _, source := range l.sources { 71 | if err := source.Start(); err != nil { 72 | return fmt.Errorf("startup error: %w", err) 73 | } 74 | primeStack = append(primeStack, source) 75 | } 76 | for _, out := range l.outputs { 77 | if err := out.Start(); err != nil { 78 | return fmt.Errorf("startup error: %w", err) 79 | } 80 | primeStack = append(primeStack, out) 81 | } 82 | log.Println("Listener is now operational.") 83 | <-ctx.Done() 84 | log.Println("Listener is shutting down.") 85 | return nil 86 | } 87 | 88 | func (l *Listener) Groups() []uint64 { 89 | res := make([]uint64, 0, len(l.groups)) 90 | for gid := range l.groups { 91 | res = append(res, gid) 92 | } 93 | return res 94 | } 95 | 96 | func (l *Listener) ListGroup(id uint64) []iface.GroupItem { 97 | g, ok := l.groups[id] 98 | if !ok { 99 | return nil 100 | } 101 | return g.List() 102 | } 103 | 104 | func (l *Listener) GroupReady(id uint64) bool { 105 | g, ok := l.groups[id] 106 | if !ok { 107 | return true 108 | } 109 | return g.Ready() 110 | } 111 | 112 | func (l *Listener) GroupReadinessBarrier(id uint64) <-chan struct{} { 113 | g, ok := l.groups[id] 114 | if !ok { 115 | ch := make(chan struct{}) 116 | close(ch) 117 | return ch 118 | } 119 | return g.ReadinessBarrier() 120 | } 121 | 122 | func (l *Listener) OnJoin(group uint64, cb iface.GroupEventCallback) func() { 123 | g, ok := l.groups[group] 124 | if !ok { 125 | return func() {} 126 | } 127 | return g.OnJoin(cb) 128 | } 129 | 130 | func (l *Listener) OnLeave(group uint64, cb iface.GroupEventCallback) func() { 131 | g, ok := l.groups[group] 132 | if !ok { 133 | return func() {} 134 | } 135 | return g.OnLeave(cb) 136 | } 137 | -------------------------------------------------------------------------------- /cmd/rgap/agent.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "net/netip" 6 | "os" 7 | "time" 8 | 9 | "github.com/spf13/cobra" 10 | 11 | "github.com/SenseUnit/rgap/agent" 12 | "github.com/SenseUnit/rgap/config" 13 | "github.com/SenseUnit/rgap/psk" 14 | ) 15 | 16 | const ( 17 | envPSK = "RGAP_PSK" 18 | envAddress = "RGAP_ADDRESS" 19 | ) 20 | 21 | var ( 22 | group uint64 23 | address addressOption 24 | key pskOption 25 | interval time.Duration 26 | destinations []string 27 | ) 28 | 29 | type addressOption struct { 30 | addr *netip.Addr 31 | } 32 | 33 | func (a *addressOption) String() string { 34 | if a == nil || a.addr == nil { 35 | return "" 36 | } 37 | return a.addr.String() 38 | } 39 | 40 | func (a *addressOption) Set(s string) error { 41 | addr, err := netip.ParseAddr(s) 42 | if err != nil { 43 | return err 44 | } 45 | a.addr = &addr 46 | return nil 47 | } 48 | 49 | func (a *addressOption) Type() string { 50 | return "ip" 51 | } 52 | 53 | type pskOption struct { 54 | psk *psk.PSK 55 | } 56 | 57 | func (pskOpt *pskOption) String() string { 58 | if pskOpt.psk == nil { 59 | return "" 60 | } 61 | return pskOpt.psk.String() 62 | } 63 | 64 | func (pskOpt *pskOption) Set(s string) error { 65 | newPSK := new(psk.PSK) 66 | if err := newPSK.FromHexString(s); err != nil { 67 | return err 68 | } 69 | pskOpt.psk = newPSK 70 | return nil 71 | } 72 | 73 | func (_ *pskOption) Type() string { 74 | return "hexstring" 75 | } 76 | 77 | // agentCmd represents the agent command 78 | var agentCmd = &cobra.Command{ 79 | Use: "agent", 80 | Short: "Run agent to send announcements", 81 | RunE: func(cmd *cobra.Command, args []string) error { 82 | if address.addr == nil { 83 | envAddressVal, ok := os.LookupEnv(envAddress) 84 | if !ok { 85 | return fmt.Errorf("announced address is not specified neither in command line argument nor in %s environment variable", envAddress) 86 | } 87 | if err := address.Set(envAddressVal); err != nil { 88 | return err 89 | } 90 | } 91 | if key.psk == nil { 92 | hexpsk, ok := os.LookupEnv(envPSK) 93 | if !ok { 94 | return fmt.Errorf("PSK is not specified neither in command line argument nor in %s environment variable", envPSK) 95 | } 96 | if err := key.Set(hexpsk); err != nil { 97 | return err 98 | } 99 | } 100 | cfg := &config.AgentConfig{ 101 | Group: group, 102 | Address: *address.addr, 103 | Key: *key.psk, 104 | Interval: interval, 105 | Destinations: destinations, 106 | } 107 | return agent.NewAgent(cfg).Run(cmd.Context()) 108 | }, 109 | } 110 | 111 | func init() { 112 | rootCmd.AddCommand(agentCmd) 113 | 114 | // Here you will define your flags and configuration settings. 115 | 116 | // Cobra supports Persistent Flags which will work for this command 117 | // and all subcommands, e.g.: 118 | // agentCmd.PersistentFlags().String("foo", "", "A help for foo") 119 | 120 | // Cobra supports local flags which will only run when this command 121 | // is called directly, e.g.: 122 | // agentCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle") 123 | agentCmd.Flags().Uint64VarP(&group, "group", "g", 0, "redundancy group") 124 | agentCmd.Flags().VarP(&address, "address", "a", "IP address to announce") 125 | agentCmd.Flags().VarP(&key, "psk", "k", "pre-shared key for announcement signature") 126 | agentCmd.Flags().DurationVarP(&interval, "interval", "i", 0, "announcement interval. If not specified agent sends one announce and exits") 127 | agentCmd.Flags().StringArrayVarP(&destinations, "dst", "d", []string{"239.82.71.65:8271"}, "announcement destination address:port. Can be specified multiple times") 128 | } 129 | -------------------------------------------------------------------------------- /agent/agent.go: -------------------------------------------------------------------------------- 1 | package agent 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | "net" 8 | "net/netip" 9 | "strings" 10 | "sync" 11 | "time" 12 | 13 | "github.com/SenseUnit/rgap/config" 14 | "github.com/SenseUnit/rgap/protocol" 15 | "github.com/SenseUnit/rgap/util" 16 | "github.com/hashicorp/go-multierror" 17 | ) 18 | 19 | type Agent struct { 20 | cfg *config.AgentConfig 21 | } 22 | 23 | func NewAgent(cfg *config.AgentConfig) *Agent { 24 | a := &Agent{ 25 | cfg: cfg, 26 | } 27 | if a.cfg.Dialer == nil { 28 | a.cfg.Dialer = new(net.Dialer) 29 | } 30 | return a 31 | } 32 | 33 | func (a *Agent) Run(ctx context.Context) error { 34 | if a.cfg.Interval <= 0 { 35 | return a.singleRun(ctx, time.Now()) 36 | } 37 | 38 | shoot := func(t time.Time) { 39 | runCtx, done := context.WithTimeout(ctx, a.cfg.Interval) 40 | defer done() 41 | err := a.singleRun(runCtx, t) 42 | if err != nil { 43 | log.Printf("run error: %v", err) 44 | } 45 | } 46 | 47 | ticker := time.NewTicker(a.cfg.Interval) 48 | defer ticker.Stop() 49 | shoot(time.Now()) 50 | for { 51 | select { 52 | case <-ctx.Done(): 53 | return nil 54 | case t := <-ticker.C: 55 | shoot(t) 56 | } 57 | } 58 | } 59 | 60 | func (a *Agent) singleRun(ctx context.Context, t time.Time) error { 61 | announcement := protocol.Announcement{ 62 | Data: protocol.AnnouncementData{ 63 | Version: protocol.V1, 64 | RedundancyID: a.cfg.Group, 65 | Timestamp: t.UnixMicro(), 66 | AnnouncedAddress: a.cfg.Address.As16(), 67 | }, 68 | } 69 | sig, err := announcement.Data.CalculateSignature(a.cfg.Key) 70 | if err != nil { 71 | return fmt.Errorf("can't sign announcement %#v: %w", announcement, err) 72 | } 73 | announcement.Signature = sig 74 | msg, err := announcement.MarshalBinary() 75 | if err != nil { 76 | return fmt.Errorf("can't marshal announcement %#v: %w", announcement, err) 77 | } 78 | var wg sync.WaitGroup 79 | errors := make([]error, len(a.cfg.Destinations)) 80 | for i, dst := range a.cfg.Destinations { 81 | wg.Add(1) 82 | go func(i int, dst string) { 83 | defer wg.Done() 84 | errors[i] = a.sendSingle(ctx, msg, dst) 85 | }(i, dst) 86 | } 87 | wg.Wait() 88 | var resErr error 89 | for _, err := range errors { 90 | if err != nil { 91 | resErr = multierror.Append(resErr, err) 92 | } 93 | } 94 | return resErr 95 | } 96 | 97 | func (a *Agent) sendSingle(ctx context.Context, msg []byte, dst string) error { 98 | dstAddr, iface, err := util.SplitAndResolveAddrSpec(dst) 99 | if err != nil { 100 | return fmt.Errorf("destination %s: interface resolving failed: %w", dst, err) 101 | } 102 | 103 | conn, err := a.dialInterfaceContext(ctx, "udp", dstAddr, iface) 104 | if err != nil { 105 | return fmt.Errorf("Agent.sendSingle dial failed: %w", err) 106 | } 107 | connCloseSignal := make(chan struct{}) 108 | defer close(connCloseSignal) 109 | go func() { 110 | select { 111 | case <-connCloseSignal: 112 | conn.Close() 113 | case <-ctx.Done(): 114 | conn.Close() 115 | } 116 | }() 117 | if _, err := conn.Write(msg); err != nil { 118 | return fmt.Errorf("Agent.sendSingle send failed: %w", err) 119 | } 120 | return nil 121 | } 122 | 123 | func (a *Agent) dialInterfaceContext(ctx context.Context, network, addr string, iif *net.Interface) (net.Conn, error) { 124 | if iif == nil { 125 | return a.cfg.Dialer.DialContext(ctx, network, addr) 126 | } 127 | 128 | var hints []string 129 | addrs, err := iif.Addrs() 130 | if err != nil { 131 | return nil, err 132 | } 133 | for _, addr := range addrs { 134 | ipnet, ok := addr.(*net.IPNet) 135 | if !ok { 136 | return nil, fmt.Errorf("unexpected type returned as address interface: %T", addr) 137 | } 138 | netipAddr, ok := netip.AddrFromSlice(ipnet.IP) 139 | if !ok { 140 | return nil, fmt.Errorf("interface %v has invalid address %s", iif.Name, ipnet.IP) 141 | } 142 | hints = append(hints, netipAddr.Unmap().String()) 143 | } 144 | boundDialer := util.NewBoundDialer(a.cfg.Dialer, strings.Join(hints, ",")) 145 | return boundDialer.DialContext(ctx, network, addr) 146 | } 147 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 2 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 3 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 4 | github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA= 5 | github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= 6 | github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= 7 | github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= 8 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 9 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 10 | github.com/jellydator/ttlcache/v3 v3.2.1-0.20240611075242-62c37338e6b2 h1:toFmGwXHI94OfHsQodQGDnvgfRHSd1nF9qCxZno/J7Y= 11 | github.com/jellydator/ttlcache/v3 v3.2.1-0.20240611075242-62c37338e6b2/go.mod h1:ruKElSF8bPER9robvf8aw6JcdlqwOPlSaZxDi3d9T+U= 12 | github.com/miekg/dns v1.1.58 h1:ca2Hdkz+cDg/7eNF6V56jjzuZ4aCAE+DbVkILdQWG/4= 13 | github.com/miekg/dns v1.1.58/go.mod h1:Ypv+3b/KadlvW9vJfXOTf300O4UqaHFzFCuHz+rPkBY= 14 | github.com/natefinch/atomic v1.0.1 h1:ZPYKxkqQOx3KZ+RsbnP/YsgvxWQPGxjC0oBt2AhwV0A= 15 | github.com/natefinch/atomic v1.0.1/go.mod h1:N/D/ELrljoqDyT3rZrsUmtsuzvHkeB/wWjHV22AZRbM= 16 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 17 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 18 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 19 | github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= 20 | github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= 21 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= 22 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 23 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 24 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 25 | github.com/valyala/fastrand v1.1.0 h1:f+5HkLW4rsgzdNoleUOB69hyT9IlD2ZQh9GyDMfb5G8= 26 | github.com/valyala/fastrand v1.1.0/go.mod h1:HWqCzkrkg6QXT8V2EXWvXCoow7vLwOFN002oeRzjapQ= 27 | go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= 28 | go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= 29 | golang.org/x/exp v0.0.0-20240119083558-1b970713d09a h1:Q8/wZp0KX97QFTc2ywcOE0YRjZPVIx+MXInMzdvQqcA= 30 | golang.org/x/exp v0.0.0-20240119083558-1b970713d09a/go.mod h1:idGWGoKP1toJGkd5/ig9ZLuPcZBC3ewk7SzmH0uou08= 31 | golang.org/x/mod v0.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0= 32 | golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= 33 | golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= 34 | golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= 35 | golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= 36 | golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 37 | golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= 38 | golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 39 | golang.org/x/tools v0.17.0 h1:FvmRgNOcs3kOa+T20R1uhfP9F6HgG2mfxDv1vrx1Htc= 40 | golang.org/x/tools v0.17.0/go.mod h1:xsh6VxdV005rRVaS6SSAf9oiAqljS7UZUacMZ8Bnsps= 41 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 42 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 43 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 44 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 45 | pgregory.net/rand v1.0.2 h1:ASEbkvwOmY/UPF2evJPBJ8XZg71xdKWYdByqKapI7Vw= 46 | pgregory.net/rand v1.0.2/go.mod h1:EyNx8APnDE3Svi8sWgUZ5lOiz60cNZUPPBTyzOUpPl4= 47 | pgregory.net/rapid v0.4.8 h1:d+5SGZWUbJPbl3ss6tmPFqnNeQR6VDOFly+eTjwPiEw= 48 | pgregory.net/rapid v0.4.8/go.mod h1:Z5PbWqjvWR1I3UGjvboUuan4fe4ZYEYNLNQLExzCoUs= 49 | -------------------------------------------------------------------------------- /output/hostsfile.go: -------------------------------------------------------------------------------- 1 | package output 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "fmt" 7 | "log" 8 | "strings" 9 | "time" 10 | 11 | atomicfile "github.com/natefinch/atomic" 12 | 13 | "github.com/SenseUnit/rgap/config" 14 | "github.com/SenseUnit/rgap/iface" 15 | "github.com/SenseUnit/rgap/util" 16 | ) 17 | 18 | type GroupHostMapping struct { 19 | Group uint64 20 | Hostname string 21 | FallbackAddresses []util.IPAddr `yaml:"fallback_addresses"` 22 | } 23 | 24 | type HostsFileConfig struct { 25 | Interval time.Duration 26 | Filename string 27 | Mappings []GroupHostMapping 28 | PrependLines []string `yaml:"prepend_lines"` 29 | AppendLines []string `yaml:"append_lines"` 30 | } 31 | 32 | type HostsFile struct { 33 | bridge iface.GroupBridge 34 | interval time.Duration 35 | filename string 36 | mappings []GroupHostMapping 37 | prependLines []string 38 | appendLines []string 39 | ctx context.Context 40 | ctxCancel func() 41 | loopDone chan struct{} 42 | } 43 | 44 | func NewHostsFile(cfg *config.OutputConfig, bridge iface.GroupBridge) (*HostsFile, error) { 45 | var hc HostsFileConfig 46 | if err := util.CheckedUnmarshal(&cfg.Spec, &hc); err != nil { 47 | return nil, fmt.Errorf("cannot unmarshal log output config: %w", err) 48 | } 49 | if hc.Interval <= 0 { 50 | return nil, fmt.Errorf("incorrect hosts file update interval: %v", hc.Interval) 51 | } 52 | if hc.Filename == "" { 53 | return nil, fmt.Errorf("filename is not specified") 54 | } 55 | for i, mapping := range hc.Mappings { 56 | if mapping.Hostname == "" { 57 | return nil, fmt.Errorf("mapping with index %d has no hostname defined", i) 58 | } 59 | } 60 | prependLines := make([]string, 0, len(hc.PrependLines)) 61 | for _, line := range hc.PrependLines { 62 | prependLines = append(prependLines, strings.TrimRight(line, "\r\n")) 63 | } 64 | appendLines := make([]string, 0, len(hc.AppendLines)) 65 | for _, line := range hc.AppendLines { 66 | appendLines = append(appendLines, strings.TrimRight(line, "\r\n")) 67 | } 68 | return &HostsFile{ 69 | bridge: bridge, 70 | interval: hc.Interval, 71 | filename: hc.Filename, 72 | mappings: hc.Mappings, 73 | prependLines: prependLines, 74 | appendLines: appendLines, 75 | }, nil 76 | } 77 | 78 | func (o *HostsFile) Start() error { 79 | ctx, cancel := context.WithCancel(context.Background()) 80 | o.ctx = ctx 81 | o.ctxCancel = cancel 82 | o.loopDone = make(chan struct{}) 83 | go o.loop() 84 | log.Printf("started hostsfile (%s) output plugin", o.filename) 85 | return nil 86 | } 87 | 88 | func (o *HostsFile) Stop() error { 89 | o.ctxCancel() 90 | <-o.loopDone 91 | log.Printf("stopped hostsfile (%s) output plugin", o.filename) 92 | return nil 93 | } 94 | 95 | func (o *HostsFile) loop() { 96 | defer close(o.loopDone) 97 | ticker := time.NewTicker(o.interval) 98 | defer ticker.Stop() 99 | for { 100 | select { 101 | case <-o.ctx.Done(): 102 | return 103 | case <-ticker.C: 104 | o.dump() 105 | } 106 | } 107 | } 108 | 109 | func (o *HostsFile) dump() { 110 | var notReadyGroups []uint64 111 | for _, mapping := range o.mappings { 112 | if !o.bridge.GroupReady(mapping.Group) { 113 | notReadyGroups = append(notReadyGroups, mapping.Group) 114 | } 115 | } 116 | if len(notReadyGroups) > 0 { 117 | log.Printf("hostsfile: skipping update because following groups are not ready yet: %v", notReadyGroups) 118 | return 119 | } 120 | 121 | var buf bytes.Buffer 122 | for _, line := range o.prependLines { 123 | fmt.Fprintln(&buf, line) 124 | } 125 | for _, mapping := range o.mappings { 126 | items := o.bridge.ListGroup(mapping.Group) 127 | if len(items) == 0 { 128 | for _, addr := range mapping.FallbackAddresses { 129 | fmt.Fprintf(&buf, "%s %s # fallback address used!\n", addr.String(), mapping.Hostname) 130 | } 131 | continue 132 | } 133 | for _, item := range items { 134 | fmt.Fprintf(&buf, "%s %s\n", item.Address().Unmap().String(), mapping.Hostname) 135 | } 136 | } 137 | for _, line := range o.appendLines { 138 | fmt.Fprintln(&buf, line) 139 | } 140 | if err := atomicfile.WriteFile(o.filename, &buf); err != nil { 141 | log.Printf("unable to update destination file: %v", err) 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /listener/group.go: -------------------------------------------------------------------------------- 1 | package listener 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | "net/netip" 8 | "sync/atomic" 9 | "time" 10 | 11 | "github.com/SenseUnit/rgap/config" 12 | "github.com/SenseUnit/rgap/iface" 13 | "github.com/SenseUnit/rgap/protocol" 14 | "github.com/SenseUnit/rgap/psk" 15 | "github.com/SenseUnit/rgap/util" 16 | "github.com/jellydator/ttlcache/v3" 17 | ) 18 | 19 | type Group struct { 20 | id uint64 21 | psk psk.PSK 22 | expire time.Duration 23 | clockSkew time.Duration 24 | readinessDelay time.Duration 25 | addrSet *ttlcache.Cache[netip.Addr, struct{}] 26 | ready atomic.Bool 27 | readinessBarrier chan struct{} 28 | readinessTimer *time.Timer 29 | } 30 | 31 | type groupItem struct { 32 | address netip.Addr 33 | expiresAt time.Time 34 | } 35 | 36 | func (gi groupItem) Address() netip.Addr { 37 | return gi.address 38 | } 39 | 40 | func (gi groupItem) ExpiresAt() time.Time { 41 | return gi.expiresAt 42 | } 43 | 44 | func GroupFromConfig(cfg *config.GroupConfig) (*Group, error) { 45 | if cfg.PSK == nil { 46 | return nil, fmt.Errorf("group %d: PSK is not set", cfg.ID) 47 | } 48 | if cfg.Expire <= 0 { 49 | return nil, fmt.Errorf("group %d: incorrect expiration time", cfg.Expire) 50 | } 51 | g := &Group{ 52 | id: cfg.ID, 53 | psk: *cfg.PSK, 54 | expire: cfg.Expire, 55 | clockSkew: cfg.ClockSkew, 56 | readinessDelay: cfg.ReadinessDelay, 57 | readinessBarrier: make(chan struct{}), 58 | addrSet: ttlcache.New[netip.Addr, struct{}]( 59 | ttlcache.WithDisableTouchOnHit[netip.Addr, struct{}](), 60 | ), 61 | } 62 | if g.clockSkew <= 0 { 63 | g.clockSkew = g.expire 64 | } 65 | if g.clockSkew > g.expire { 66 | // we'll cap it by expiration time anyway, 67 | // as well as not allow messages from distant future 68 | g.clockSkew = g.expire 69 | } 70 | return g, nil 71 | } 72 | 73 | func (g *Group) ID() uint64 { 74 | return g.id 75 | } 76 | 77 | func (g *Group) Start() error { 78 | go g.addrSet.Start() 79 | g.readinessTimer = time.AfterFunc(g.readinessDelay, func() { 80 | g.ready.Store(true) 81 | close(g.readinessBarrier) 82 | }) 83 | log.Printf("Group %d was started.", g.id) 84 | return nil 85 | } 86 | 87 | func (g *Group) Stop() error { 88 | g.addrSet.Stop() 89 | if g.readinessTimer != nil { 90 | g.readinessTimer.Stop() 91 | } 92 | log.Printf("Group %d was destroyed.", g.id) 93 | return nil 94 | } 95 | 96 | func (g *Group) Ingest(a *protocol.Announcement) error { 97 | if a.Data.Version != protocol.V1 { 98 | return nil 99 | } 100 | now := time.Now() 101 | announceTime := time.UnixMicro(a.Data.Timestamp) 102 | timeDrift := now.Sub(announceTime) 103 | if timeDrift.Abs() > g.clockSkew { 104 | return nil 105 | } 106 | ok, err := a.CheckSignature(g.psk) 107 | if err != nil { 108 | // normally shouldn't happen. Notify user by raising this error. 109 | return fmt.Errorf("announce verification failed: %w", err) 110 | } 111 | if !ok { 112 | return nil 113 | } 114 | address := netip.AddrFrom16(a.Data.AnnouncedAddress) 115 | expireAt := announceTime.Add(g.expire) 116 | setItem := g.addrSet.Get(address) 117 | if setItem == nil || setItem.ExpiresAt().Before(expireAt) { 118 | g.addrSet.Set(address, struct{}{}, util.Max(expireAt.Sub(now), 1)) 119 | } 120 | return nil 121 | } 122 | 123 | func (g *Group) List() []iface.GroupItem { 124 | items := g.addrSet.Items() 125 | res := make([]iface.GroupItem, 0, len(items)) 126 | for _, item := range items { 127 | if item.IsExpired() { 128 | continue 129 | } 130 | res = append(res, groupItem{ 131 | address: item.Key(), 132 | expiresAt: item.ExpiresAt(), 133 | }) 134 | } 135 | return res 136 | } 137 | 138 | func (g *Group) Ready() bool { 139 | return g.ready.Load() 140 | } 141 | 142 | func (g *Group) ReadinessBarrier() <-chan struct{} { 143 | return g.readinessBarrier 144 | } 145 | 146 | func (g *Group) OnJoin(cb iface.GroupEventCallback) func() { 147 | return g.addrSet.OnInsertion(func(_ context.Context, item *ttlcache.Item[netip.Addr, struct{}]) { 148 | cb(g.id, groupItem{ 149 | address: item.Key(), 150 | expiresAt: item.ExpiresAt(), 151 | }) 152 | }) 153 | } 154 | 155 | func (g *Group) OnLeave(cb iface.GroupEventCallback) func() { 156 | return g.addrSet.OnEviction(func(_ context.Context, _ ttlcache.EvictionReason, item *ttlcache.Item[netip.Addr, struct{}]) { 157 | cb(g.id, groupItem{ 158 | address: item.Key(), 159 | expiresAt: item.ExpiresAt(), 160 | }) 161 | }) 162 | } 163 | -------------------------------------------------------------------------------- /util/hintdialer.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "net" 8 | "os" 9 | "strings" 10 | 11 | "github.com/hashicorp/go-multierror" 12 | ) 13 | 14 | var ( 15 | ErrNoSuitableAddress = errors.New("no suitable address") 16 | ErrBadIPAddressLength = errors.New("bad IP address length") 17 | ErrUnknownNetwork = errors.New("unknown network") 18 | ) 19 | 20 | type BoundDialerContextKey struct{} 21 | 22 | type BoundDialerContextValue struct { 23 | Hints *string 24 | LocalAddr string 25 | } 26 | 27 | type BoundDialerDefaultSink interface { 28 | DialContext(ctx context.Context, network, address string) (net.Conn, error) 29 | } 30 | 31 | type BoundDialer struct { 32 | defaultDialer BoundDialerDefaultSink 33 | defaultHints string 34 | } 35 | 36 | func NewBoundDialer(defaultDialer BoundDialerDefaultSink, defaultHints string) *BoundDialer { 37 | if defaultDialer == nil { 38 | defaultDialer = &net.Dialer{} 39 | } 40 | return &BoundDialer{ 41 | defaultDialer: defaultDialer, 42 | defaultHints: defaultHints, 43 | } 44 | } 45 | 46 | func (d *BoundDialer) DialContext(ctx context.Context, network, address string) (net.Conn, error) { 47 | hints := d.defaultHints 48 | lAddr := "" 49 | if hintsOverride := ctx.Value(BoundDialerContextKey{}); hintsOverride != nil { 50 | if hintsOverrideValue, ok := hintsOverride.(BoundDialerContextValue); ok { 51 | if hintsOverrideValue.Hints != nil { 52 | hints = *hintsOverrideValue.Hints 53 | } 54 | lAddr = hintsOverrideValue.LocalAddr 55 | } 56 | } 57 | 58 | parsedHints, err := parseHints(hints, lAddr) 59 | if err != nil { 60 | return nil, fmt.Errorf("dial failed: %w", err) 61 | } 62 | 63 | if len(parsedHints) == 0 { 64 | return d.defaultDialer.DialContext(ctx, network, address) 65 | } 66 | 67 | var netBase string 68 | switch network { 69 | case "tcp", "tcp4", "tcp6": 70 | netBase = "tcp" 71 | case "udp", "udp4", "udp6": 72 | netBase = "udp" 73 | case "ip", "ip4", "ip6": 74 | netBase = "ip" 75 | default: 76 | return d.defaultDialer.DialContext(ctx, network, address) 77 | } 78 | 79 | var resErr error 80 | for _, lIP := range parsedHints { 81 | lAddr, restrictedNetwork, err := ipToLAddr(netBase, lIP) 82 | if err != nil { 83 | resErr = multierror.Append(resErr, fmt.Errorf("ipToLAddr(%q) failed: %w", lIP.String(), err)) 84 | continue 85 | } 86 | if network != netBase && network != restrictedNetwork { 87 | continue 88 | } 89 | 90 | conn, err := (&net.Dialer{ 91 | LocalAddr: lAddr, 92 | }).DialContext(ctx, restrictedNetwork, address) 93 | if err != nil { 94 | resErr = multierror.Append(resErr, fmt.Errorf("dial failed: %w", err)) 95 | } else { 96 | return conn, nil 97 | } 98 | } 99 | 100 | if resErr == nil { 101 | resErr = ErrNoSuitableAddress 102 | } 103 | return nil, resErr 104 | } 105 | 106 | func (d *BoundDialer) Dial(network, address string) (net.Conn, error) { 107 | return d.DialContext(context.Background(), network, address) 108 | } 109 | 110 | func ipToLAddr(network string, ip net.IP) (net.Addr, string, error) { 111 | v6 := true 112 | if ip4 := ip.To4(); len(ip4) == net.IPv4len { 113 | ip = ip4 114 | v6 = false 115 | } else if len(ip) != net.IPv6len { 116 | return nil, "", ErrBadIPAddressLength 117 | } 118 | 119 | var lAddr net.Addr 120 | var lNetwork string 121 | switch network { 122 | case "tcp", "tcp4", "tcp6": 123 | lAddr = &net.TCPAddr{ 124 | IP: ip, 125 | } 126 | if v6 { 127 | lNetwork = "tcp6" 128 | } else { 129 | lNetwork = "tcp4" 130 | } 131 | case "udp", "udp4", "udp6": 132 | lAddr = &net.UDPAddr{ 133 | IP: ip, 134 | } 135 | if v6 { 136 | lNetwork = "udp6" 137 | } else { 138 | lNetwork = "udp4" 139 | } 140 | case "ip", "ip4", "ip6": 141 | lAddr = &net.IPAddr{ 142 | IP: ip, 143 | } 144 | if v6 { 145 | lNetwork = "ip6" 146 | } else { 147 | lNetwork = "ip4" 148 | } 149 | default: 150 | return nil, "", ErrUnknownNetwork 151 | } 152 | 153 | return lAddr, lNetwork, nil 154 | } 155 | 156 | func parseHints(hints, lAddr string) ([]net.IP, error) { 157 | hints = os.Expand(hints, func(key string) string { 158 | switch key { 159 | case "lAddr": 160 | return lAddr 161 | default: 162 | return fmt.Sprintf("", key) 163 | } 164 | }) 165 | res, err := parseIPList(hints) 166 | if err != nil { 167 | return nil, fmt.Errorf("unable to parse source IP hints %q: %w", hints, err) 168 | } 169 | return res, nil 170 | } 171 | 172 | func parseIPList(list string) ([]net.IP, error) { 173 | res := make([]net.IP, 0) 174 | for _, elem := range strings.Split(list, ",") { 175 | elem = strings.TrimSpace(elem) 176 | if len(elem) == 0 { 177 | continue 178 | } 179 | if parsed := net.ParseIP(elem); parsed == nil { 180 | return nil, fmt.Errorf("unable to parse IP address %q", elem) 181 | } else { 182 | res = append(res, parsed) 183 | } 184 | } 185 | return res, nil 186 | } 187 | -------------------------------------------------------------------------------- /output/command.go: -------------------------------------------------------------------------------- 1 | package output 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "errors" 7 | "fmt" 8 | "log" 9 | "os/exec" 10 | "sync" 11 | "time" 12 | 13 | "github.com/SenseUnit/rgap/config" 14 | "github.com/SenseUnit/rgap/iface" 15 | "github.com/SenseUnit/rgap/util" 16 | ) 17 | 18 | type CommandConfig struct { 19 | Group *uint64 20 | Command []string 21 | Timeout time.Duration 22 | Retries *int 23 | WaitDelay *time.Duration `yaml:"wait_delay"` 24 | } 25 | 26 | type Command struct { 27 | bridge iface.GroupBridge 28 | group uint64 29 | command []string 30 | timeout time.Duration 31 | retries int 32 | waitDelay time.Duration 33 | syncQueue chan struct{} 34 | shutdown chan struct{} 35 | busy sync.WaitGroup 36 | unsubFns []func() 37 | } 38 | 39 | func NewCommand(cfg *config.OutputConfig, bridge iface.GroupBridge) (*Command, error) { 40 | var cc CommandConfig 41 | if err := util.CheckedUnmarshal(&cfg.Spec, &cc); err != nil { 42 | return nil, fmt.Errorf("cannot unmarshal command output config: %w", err) 43 | } 44 | if cc.Group == nil { 45 | return nil, errors.New("group is not specified") 46 | } 47 | if len(cc.Command) == 0 { 48 | return nil, errors.New("command is not specified") 49 | } 50 | waitDelay := 100 * time.Millisecond 51 | if cc.WaitDelay != nil { 52 | waitDelay = *cc.WaitDelay 53 | } 54 | retries := 1 55 | if cc.Retries != nil && *cc.Retries > 1 { 56 | retries = *cc.Retries 57 | } 58 | return &Command{ 59 | bridge: bridge, 60 | group: *cc.Group, 61 | command: cc.Command, 62 | timeout: cc.Timeout, 63 | retries: retries, 64 | waitDelay: waitDelay, 65 | syncQueue: make(chan struct{}, 1), 66 | shutdown: make(chan struct{}), 67 | }, nil 68 | } 69 | 70 | func (o *Command) Start() error { 71 | // This addition to WaitGroup must happen before any Wait() 72 | // therefore it is synchronized with startup and holds back 73 | // delivery of events before 74 | o.busy.Add(1) 75 | go func() { 76 | defer o.busy.Done() 77 | o.syncLoop() 78 | }() 79 | o.unsubFns = append(o.unsubFns, 80 | o.bridge.OnJoin(o.group, func(group uint64, item iface.GroupItem) { 81 | o.sync() 82 | }), 83 | o.bridge.OnLeave(o.group, func(group uint64, item iface.GroupItem) { 84 | o.sync() 85 | }), 86 | ) 87 | o.sync() 88 | log.Println("started command output plugin") 89 | return nil 90 | } 91 | 92 | func (o *Command) Stop() error { 93 | for _, unsub := range o.unsubFns { 94 | unsub() 95 | } 96 | close(o.shutdown) 97 | log.Println("command output plugin stopping - waiting commands to finish...") 98 | o.busy.Wait() 99 | log.Println("stopped command output plugin") 100 | return nil 101 | } 102 | 103 | func (o *Command) sync() { 104 | select { 105 | case o.syncQueue <- struct{}{}: 106 | default: 107 | } 108 | } 109 | 110 | func (o *Command) syncLoop() { 111 | select { 112 | case <-o.shutdown: 113 | return 114 | case <-o.bridge.GroupReadinessBarrier(o.group): 115 | } 116 | for { 117 | select { 118 | case <-o.shutdown: 119 | return 120 | case <-o.syncQueue: 121 | o.runCommand() 122 | } 123 | } 124 | } 125 | 126 | func (o *Command) runCommand() { 127 | for i := 0; i < o.retries; i++ { 128 | err := o.runCommandAttempt() 129 | if err != nil { 130 | var ee *exec.ExitError 131 | if errors.As(err, &ee) { 132 | log.Printf("command %v exited with code %d", o.command, ee.ExitCode()) 133 | } else { 134 | log.Printf("command %v run error: %v", o.command, err) 135 | } 136 | } else { 137 | log.Printf("command %v succeeded", o.command) 138 | return 139 | } 140 | } 141 | log.Printf("command %v all attempts failed!", o.command) 142 | } 143 | 144 | func (o *Command) runCommandAttempt() error { 145 | ctx := context.Background() 146 | if o.timeout > 0 { 147 | ctx1, cancel := context.WithTimeout(ctx, o.timeout) 148 | defer cancel() 149 | ctx = ctx1 150 | } 151 | 152 | cmd := exec.CommandContext(ctx, o.command[0], o.command[1:]...) 153 | cmd.WaitDelay = o.waitDelay 154 | 155 | var stdinBuf bytes.Buffer 156 | for _, item := range o.bridge.ListGroup(o.group) { 157 | fmt.Fprintln(&stdinBuf, item.Address().Unmap().String()) 158 | } 159 | cmd.Stdin = &stdinBuf 160 | 161 | stdout := newOutputForwarder("stdout", o.command) 162 | defer stdout.Close() 163 | cmd.Stdout = stdout 164 | 165 | stderr := newOutputForwarder("stderr", o.command) 166 | defer stderr.Close() 167 | cmd.Stderr = stderr 168 | 169 | log.Printf("starting sync command %v...", o.command) 170 | return cmd.Run() 171 | } 172 | 173 | type outputForwarder struct { 174 | name string 175 | command []string 176 | buf []byte 177 | } 178 | 179 | func newOutputForwarder(name string, command []string) *outputForwarder { 180 | return &outputForwarder{ 181 | name: name, 182 | command: command, 183 | } 184 | } 185 | 186 | func dropCR(data []byte) []byte { 187 | if len(data) > 0 && data[len(data)-1] == '\r' { 188 | return data[0 : len(data)-1] 189 | } 190 | return data 191 | } 192 | 193 | func (of *outputForwarder) Write(p []byte) (int, error) { 194 | n := len(p) 195 | for i := bytes.IndexByte(p, '\n'); i >= 0; i = bytes.IndexByte(p, '\n') { 196 | yield := dropCR(p[:i]) 197 | if len(of.buf) > 0 { 198 | log.Printf("command %v %s: %s%s", of.command, of.name, of.buf, yield) 199 | of.buf = nil 200 | } else { 201 | log.Printf("command %v %s: %s", of.command, of.name, yield) 202 | } 203 | p = p[i+1:] 204 | } 205 | if len(p) > 0 { 206 | of.buf = make([]byte, len(p)) 207 | copy(of.buf, p) 208 | } 209 | return n, nil 210 | } 211 | 212 | func (of *outputForwarder) Close() error { 213 | if len(of.buf) > 0 { 214 | log.Printf("command %v %s: %s", of.command, of.name, of.buf) 215 | of.buf = nil 216 | } 217 | return nil 218 | } 219 | -------------------------------------------------------------------------------- /output/dns.go: -------------------------------------------------------------------------------- 1 | package output 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "strings" 7 | "time" 8 | 9 | "github.com/miekg/dns" 10 | "pgregory.net/rand" 11 | 12 | "github.com/SenseUnit/rgap/config" 13 | "github.com/SenseUnit/rgap/iface" 14 | "github.com/SenseUnit/rgap/util" 15 | ) 16 | 17 | type DNSMapping struct { 18 | Group uint64 19 | FallbackAddresses []util.IPAddr `yaml:"fallback_addresses"` 20 | } 21 | 22 | type DNSServerConfig struct { 23 | BindAddress string `yaml:"bind_address"` 24 | Mappings map[string]DNSMapping 25 | Compress bool 26 | NonAuthoritative bool `yaml:"non_authoritative"` 27 | } 28 | 29 | type DNSServer struct { 30 | bridge iface.GroupBridge 31 | bindAddress string 32 | mappings map[string]DNSMapping 33 | compress bool 34 | authoritative bool 35 | tcpServer *dns.Server 36 | udpServer *dns.Server 37 | tcpDone chan struct{} 38 | udpDone chan struct{} 39 | rand *rand.Rand 40 | } 41 | 42 | func NewDNSServer(cfg *config.OutputConfig, bridge iface.GroupBridge) (*DNSServer, error) { 43 | var oc DNSServerConfig 44 | if err := util.CheckedUnmarshal(&cfg.Spec, &oc); err != nil { 45 | return nil, fmt.Errorf("cannot unmarshal DNS output config: %w", err) 46 | } 47 | mappings := make(map[string]DNSMapping) 48 | for name, mapping := range oc.Mappings { 49 | name = strings.ToLower(strings.TrimRight(name, ".")) 50 | mappings[name] = mapping 51 | } 52 | return &DNSServer{ 53 | bridge: bridge, 54 | bindAddress: oc.BindAddress, 55 | mappings: mappings, 56 | compress: oc.Compress, 57 | authoritative: !oc.NonAuthoritative, 58 | rand: rand.New(), 59 | }, nil 60 | } 61 | 62 | func (o *DNSServer) Start() error { 63 | var ( 64 | tcpStartupErr error 65 | udpStartupErr error 66 | ) 67 | o.tcpDone = make(chan struct{}) 68 | o.udpDone = make(chan struct{}) 69 | tcpStartupDone := make(chan struct{}) 70 | udpStartupDone := make(chan struct{}) 71 | o.tcpServer = &dns.Server{ 72 | Addr: o.bindAddress, 73 | Net: "tcp", 74 | Handler: o, 75 | UDPSize: 65536, 76 | NotifyStartedFunc: func() { close(tcpStartupDone) }, 77 | } 78 | o.udpServer = &dns.Server{ 79 | Addr: o.bindAddress, 80 | Net: "udp", 81 | Handler: o, 82 | UDPSize: 65536, 83 | NotifyStartedFunc: func() { close(udpStartupDone) }, 84 | } 85 | go func() { 86 | defer close(o.tcpDone) 87 | tcpStartupErr = o.tcpServer.ListenAndServe() 88 | }() 89 | select { 90 | case <-tcpStartupDone: 91 | case <-o.tcpDone: 92 | return fmt.Errorf("output DNS server (TCP) startup failed: %w", tcpStartupErr) 93 | } 94 | go func() { 95 | defer close(o.udpDone) 96 | udpStartupErr = o.udpServer.ListenAndServe() 97 | }() 98 | select { 99 | case <-udpStartupDone: 100 | case <-o.udpDone: 101 | o.tcpServer.Shutdown() 102 | return fmt.Errorf("output DNS server (UDP) startup failed: %w", udpStartupErr) 103 | } 104 | log.Printf("started DNS server (%s) output plugin", o.bindAddress) 105 | return nil 106 | } 107 | 108 | func (o *DNSServer) Stop() error { 109 | o.udpServer.Shutdown() 110 | o.tcpServer.Shutdown() 111 | log.Printf("stopped DNS server (%s) output plugin", o.bindAddress) 112 | return nil 113 | } 114 | 115 | func (o *DNSServer) failDNSReq(w dns.ResponseWriter, r *dns.Msg) { 116 | m := new(dns.Msg) 117 | m.Compress = o.compress 118 | m.Authoritative = o.authoritative 119 | m.SetRcode(r, dns.RcodeServerFailure) 120 | w.WriteMsg(m) 121 | } 122 | 123 | func (o *DNSServer) serveEmptyResponse(w dns.ResponseWriter, r *dns.Msg) { 124 | m := new(dns.Msg) 125 | m.Compress = o.compress 126 | m.Authoritative = o.authoritative 127 | m.SetReply(r) 128 | w.WriteMsg(m) 129 | } 130 | 131 | func (o *DNSServer) ServeDNS(w dns.ResponseWriter, r *dns.Msg) { 132 | if len(r.Question) == 0 { 133 | o.failDNSReq(w, r) 134 | return 135 | } 136 | 137 | dom := r.Question[0].Name 138 | name := strings.ToLower(strings.TrimRight(dom, ".")) 139 | qtype := r.Question[0].Qtype 140 | 141 | if r.Question[0].Qclass != dns.ClassINET { 142 | o.failDNSReq(w, r) 143 | return 144 | } 145 | 146 | log.Printf("DNS req @ %s: Name = %q, QType = %s", o.bindAddress, name, dns.Type(qtype).String()) 147 | 148 | switch qtype { 149 | case dns.TypeAAAA, dns.TypeA: 150 | default: 151 | o.failDNSReq(w, r) 152 | return 153 | } 154 | 155 | mapping, ok := o.mappings[name] 156 | if !ok { 157 | o.failDNSReq(w, r) 158 | return 159 | } 160 | 161 | if !o.bridge.GroupReady(mapping.Group) { 162 | o.failDNSReq(w, r) 163 | return 164 | } 165 | 166 | m := new(dns.Msg) 167 | m.Compress = o.compress 168 | m.Authoritative = o.authoritative 169 | 170 | items := o.bridge.ListGroup(mapping.Group) 171 | if len(items) == 0 { 172 | // group is empty - fallback needed 173 | for _, addr := range mapping.FallbackAddresses { 174 | netAddr := addr.Addr() 175 | switch qtype { 176 | case dns.TypeA: 177 | if netAddr.Is4() { 178 | m.Answer = append(m.Answer, &dns.A{ 179 | Hdr: dns.RR_Header{Name: dom, Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 0}, 180 | A: netAddr.AsSlice(), 181 | }) 182 | } 183 | case dns.TypeAAAA: 184 | if netAddr.Is6() { 185 | m.Answer = append(m.Answer, &dns.AAAA{ 186 | Hdr: dns.RR_Header{Name: dom, Rrtype: dns.TypeAAAA, Class: dns.ClassINET, Ttl: 0}, 187 | AAAA: netAddr.AsSlice(), 188 | }) 189 | } 190 | } 191 | } 192 | } else { 193 | now := time.Now() 194 | for _, item := range items { 195 | netAddr := item.Address().Unmap() 196 | ttl := uint32(util.Max(item.ExpiresAt().Sub(now).Seconds(), 0)) 197 | switch qtype { 198 | case dns.TypeA: 199 | if netAddr.Is4() { 200 | m.Answer = append(m.Answer, &dns.A{ 201 | Hdr: dns.RR_Header{Name: dom, Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: ttl}, 202 | A: netAddr.AsSlice(), 203 | }) 204 | } 205 | case dns.TypeAAAA: 206 | if netAddr.Is6() { 207 | m.Answer = append(m.Answer, &dns.AAAA{ 208 | Hdr: dns.RR_Header{Name: dom, Rrtype: dns.TypeAAAA, Class: dns.ClassINET, Ttl: ttl}, 209 | AAAA: netAddr.AsSlice(), 210 | }) 211 | } 212 | } 213 | } 214 | } 215 | rand.ShuffleSlice(o.rand, m.Answer) 216 | m.SetReply(r) 217 | w.WriteMsg(m) 218 | } 219 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | PROGNAME = rgap 2 | OUTSUFFIX = bin/$(PROGNAME) 3 | VERSION := $(shell git describe) 4 | BUILDOPTS = -a -tags netgo -trimpath -asmflags -trimpath 5 | LDFLAGS = -ldflags '-s -w -extldflags "-static" -X main.version=$(VERSION)' 6 | LDFLAGS_NATIVE = -ldflags '-s -w -X main.version=$(VERSION)' 7 | MAIN_PACKAGE = ./cmd/$(PROGNAME) 8 | 9 | GO := go 10 | 11 | src = $(wildcard *.go */*.go */*/*.go go.mod go.sum) 12 | 13 | native: bin-native 14 | all: bin-linux-amd64 bin-linux-386 bin-linux-arm bin-linux-arm64 \ 15 | bin-linux-mips bin-linux-mipsle bin-linux-mips64 bin-linux-mips64le \ 16 | bin-freebsd-amd64 bin-freebsd-386 bin-freebsd-arm bin-freebsd-arm64 \ 17 | bin-netbsd-amd64 bin-netbsd-386 bin-netbsd-arm bin-netbsd-arm64 \ 18 | bin-openbsd-amd64 bin-openbsd-386 bin-openbsd-arm bin-openbsd-arm64 \ 19 | bin-darwin-amd64 bin-darwin-arm64 \ 20 | bin-windows-amd64 bin-windows-386 bin-windows-arm 21 | 22 | bin-native: $(OUTSUFFIX) 23 | bin-linux-amd64: $(OUTSUFFIX).linux-amd64 24 | bin-linux-386: $(OUTSUFFIX).linux-386 25 | bin-linux-arm: $(OUTSUFFIX).linux-arm 26 | bin-linux-arm64: $(OUTSUFFIX).linux-arm64 27 | bin-linux-mips: $(OUTSUFFIX).linux-mips 28 | bin-linux-mipsle: $(OUTSUFFIX).linux-mipsle 29 | bin-linux-mips64: $(OUTSUFFIX).linux-mips64 30 | bin-linux-mips64le: $(OUTSUFFIX).linux-mips64le 31 | bin-freebsd-amd64: $(OUTSUFFIX).freebsd-amd64 32 | bin-freebsd-386: $(OUTSUFFIX).freebsd-386 33 | bin-freebsd-arm: $(OUTSUFFIX).freebsd-arm 34 | bin-freebsd-arm64: $(OUTSUFFIX).freebsd-arm64 35 | bin-netbsd-amd64: $(OUTSUFFIX).netbsd-amd64 36 | bin-netbsd-386: $(OUTSUFFIX).netbsd-386 37 | bin-netbsd-arm: $(OUTSUFFIX).netbsd-arm 38 | bin-netbsd-arm64: $(OUTSUFFIX).netbsd-arm64 39 | bin-openbsd-amd64: $(OUTSUFFIX).openbsd-amd64 40 | bin-openbsd-386: $(OUTSUFFIX).openbsd-386 41 | bin-openbsd-arm: $(OUTSUFFIX).openbsd-arm 42 | bin-openbsd-arm64: $(OUTSUFFIX).openbsd-arm64 43 | bin-darwin-amd64: $(OUTSUFFIX).darwin-amd64 44 | bin-darwin-arm64: $(OUTSUFFIX).darwin-arm64 45 | bin-windows-amd64: $(OUTSUFFIX).windows-amd64.exe 46 | bin-windows-386: $(OUTSUFFIX).windows-386.exe 47 | bin-windows-arm: $(OUTSUFFIX).windows-arm.exe 48 | 49 | $(OUTSUFFIX): $(src) 50 | $(GO) build $(LDFLAGS_NATIVE) -o $@ $(MAIN_PACKAGE) 51 | 52 | $(OUTSUFFIX).linux-amd64: $(src) 53 | CGO_ENABLED=0 GOOS=linux GOARCH=amd64 $(GO) build $(BUILDOPTS) $(LDFLAGS) -o $@ $(MAIN_PACKAGE) 54 | 55 | $(OUTSUFFIX).linux-386: $(src) 56 | CGO_ENABLED=0 GOOS=linux GOARCH=386 $(GO) build $(BUILDOPTS) $(LDFLAGS) -o $@ $(MAIN_PACKAGE) 57 | 58 | $(OUTSUFFIX).linux-arm: $(src) 59 | CGO_ENABLED=0 GOOS=linux GOARCH=arm $(GO) build $(BUILDOPTS) $(LDFLAGS) -o $@ $(MAIN_PACKAGE) 60 | 61 | $(OUTSUFFIX).linux-arm64: $(src) 62 | CGO_ENABLED=0 GOOS=linux GOARCH=arm64 $(GO) build $(BUILDOPTS) $(LDFLAGS) -o $@ $(MAIN_PACKAGE) 63 | 64 | $(OUTSUFFIX).linux-mips: $(src) 65 | CGO_ENABLED=0 GOOS=linux GOARCH=mips GOMIPS=softfloat $(GO) build $(BUILDOPTS) $(LDFLAGS) -o $@ $(MAIN_PACKAGE) 66 | 67 | $(OUTSUFFIX).linux-mips64: $(src) 68 | CGO_ENABLED=0 GOOS=linux GOARCH=mips64 GOMIPS=softfloat $(GO) build $(BUILDOPTS) $(LDFLAGS) -o $@ $(MAIN_PACKAGE) 69 | 70 | $(OUTSUFFIX).linux-mipsle: $(src) 71 | CGO_ENABLED=0 GOOS=linux GOARCH=mipsle GOMIPS=softfloat $(GO) build $(BUILDOPTS) $(LDFLAGS) -o $@ $(MAIN_PACKAGE) 72 | 73 | $(OUTSUFFIX).linux-mips64le: $(src) 74 | CGO_ENABLED=0 GOOS=linux GOARCH=mips64le GOMIPS=softfloat $(GO) build $(BUILDOPTS) $(LDFLAGS) -o $@ $(MAIN_PACKAGE) 75 | 76 | $(OUTSUFFIX).freebsd-amd64: $(src) 77 | CGO_ENABLED=0 GOOS=freebsd GOARCH=amd64 $(GO) build $(BUILDOPTS) $(LDFLAGS) -o $@ $(MAIN_PACKAGE) 78 | 79 | $(OUTSUFFIX).freebsd-386: $(src) 80 | CGO_ENABLED=0 GOOS=freebsd GOARCH=386 $(GO) build $(BUILDOPTS) $(LDFLAGS) -o $@ $(MAIN_PACKAGE) 81 | 82 | $(OUTSUFFIX).freebsd-arm: $(src) 83 | CGO_ENABLED=0 GOOS=freebsd GOARCH=arm $(GO) build $(BUILDOPTS) $(LDFLAGS) -o $@ $(MAIN_PACKAGE) 84 | 85 | $(OUTSUFFIX).freebsd-arm64: $(src) 86 | CGO_ENABLED=0 GOOS=freebsd GOARCH=arm64 $(GO) build $(BUILDOPTS) $(LDFLAGS) -o $@ $(MAIN_PACKAGE) 87 | 88 | $(OUTSUFFIX).netbsd-amd64: $(src) 89 | CGO_ENABLED=0 GOOS=netbsd GOARCH=amd64 $(GO) build $(BUILDOPTS) $(LDFLAGS) -o $@ $(MAIN_PACKAGE) 90 | 91 | $(OUTSUFFIX).netbsd-386: $(src) 92 | CGO_ENABLED=0 GOOS=netbsd GOARCH=386 $(GO) build $(BUILDOPTS) $(LDFLAGS) -o $@ $(MAIN_PACKAGE) 93 | 94 | $(OUTSUFFIX).netbsd-arm: $(src) 95 | CGO_ENABLED=0 GOOS=netbsd GOARCH=arm $(GO) build $(BUILDOPTS) $(LDFLAGS) -o $@ $(MAIN_PACKAGE) 96 | 97 | $(OUTSUFFIX).netbsd-arm64: $(src) 98 | CGO_ENABLED=0 GOOS=netbsd GOARCH=arm64 $(GO) build $(BUILDOPTS) $(LDFLAGS) -o $@ $(MAIN_PACKAGE) 99 | 100 | $(OUTSUFFIX).openbsd-amd64: $(src) 101 | CGO_ENABLED=0 GOOS=openbsd GOARCH=amd64 $(GO) build $(BUILDOPTS) $(LDFLAGS) -o $@ $(MAIN_PACKAGE) 102 | 103 | $(OUTSUFFIX).openbsd-386: $(src) 104 | CGO_ENABLED=0 GOOS=openbsd GOARCH=386 $(GO) build $(BUILDOPTS) $(LDFLAGS) -o $@ $(MAIN_PACKAGE) 105 | 106 | $(OUTSUFFIX).openbsd-arm: $(src) 107 | CGO_ENABLED=0 GOOS=openbsd GOARCH=arm $(GO) build $(BUILDOPTS) $(LDFLAGS) -o $@ $(MAIN_PACKAGE) 108 | 109 | $(OUTSUFFIX).openbsd-arm64: $(src) 110 | CGO_ENABLED=0 GOOS=openbsd GOARCH=arm64 $(GO) build $(BUILDOPTS) $(LDFLAGS) -o $@ $(MAIN_PACKAGE) 111 | 112 | $(OUTSUFFIX).darwin-amd64: $(src) 113 | CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 $(GO) build $(BUILDOPTS) $(LDFLAGS) -o $@ $(MAIN_PACKAGE) 114 | 115 | $(OUTSUFFIX).darwin-arm64: $(src) 116 | CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 $(GO) build $(BUILDOPTS) $(LDFLAGS) -o $@ $(MAIN_PACKAGE) 117 | 118 | $(OUTSUFFIX).windows-amd64.exe: $(src) 119 | CGO_ENABLED=0 GOOS=windows GOARCH=amd64 $(GO) build $(BUILDOPTS) $(LDFLAGS) -o $@ $(MAIN_PACKAGE) 120 | 121 | $(OUTSUFFIX).windows-386.exe: $(src) 122 | CGO_ENABLED=0 GOOS=windows GOARCH=386 $(GO) build $(BUILDOPTS) $(LDFLAGS) -o $@ $(MAIN_PACKAGE) 123 | 124 | $(OUTSUFFIX).windows-arm.exe: $(src) 125 | CGO_ENABLED=0 GOOS=windows GOARCH=arm GOARM=7 $(GO) build $(BUILDOPTS) $(LDFLAGS) -o $@ $(MAIN_PACKAGE) 126 | 127 | clean: 128 | rm -f bin/* 129 | 130 | fmt: 131 | $(GO) fmt ./... 132 | 133 | run: 134 | $(GO) run $(LDFLAGS) $(MAIN_PACKAGE) $(ARGS) 135 | 136 | install: 137 | $(GO) install $(LDFLAGS_NATIVE) $(MAIN_PACKAGE) 138 | 139 | .PHONY: clean all native fmt install \ 140 | bin-native \ 141 | bin-linux-amd64 \ 142 | bin-linux-386 \ 143 | bin-linux-arm \ 144 | bin-linux-arm64 \ 145 | bin-linux-mips \ 146 | bin-linux-mipsle \ 147 | bin-linux-mips64 \ 148 | bin-linux-mips64le \ 149 | bin-freebsd-amd64 \ 150 | bin-freebsd-386 \ 151 | bin-freebsd-arm \ 152 | bin-freebsd-arm64 \ 153 | bin-netbsd-amd64 \ 154 | bin-netbsd-386 \ 155 | bin-netbsd-arm \ 156 | bin-netbsd-arm64 \ 157 | bin-openbsd-amd64 \ 158 | bin-openbsd-386 \ 159 | bin-openbsd-arm \ 160 | bin-openbsd-arm64 \ 161 | bin-darwin-amd64 \ 162 | bin-darwin-arm64 \ 163 | bin-windows-amd64 \ 164 | bin-windows-386 \ 165 | bin-windows-arm 166 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | rgap 2 | ==== 3 | 4 | Redundancy Group Announcement Protocol 5 | 6 | RGAP allows one group of hosts to be aware about IP addresses of another group of hosts. For example, it is useful for updating load balancers on dynamic IP addresses of backend servers. 7 | 8 | Announcements are propagated in short HMAC-signed UDP messages using unicast or multicast. 9 | 10 | This implementation defines two primary parts: *agent* and *listener*. 11 | 12 | Agent periodically sends unicast or broadcast UDP message to announce it's presense in particular redundancy group. 13 | 14 | Listener accepts announces, verifies them and maintains the list of active IP addresses for each redundancy group. At the same time it exposes current list of IP addresses through its output plugins. 15 | 16 | ## Usage 17 | 18 | ### Agent example 19 | 20 | ```sh 21 | RGAP_ADDRESS=127.1.2.3 \ 22 | RGAP_PSK=8f1302643b0809279794c5cc47f236561d7442b85d748bd7d1a58adfbe9ff431 \ 23 | rgap agent -g 1000 -i 5s 24 | ``` 25 | 26 | where RGAP\_ADDRESS is actual IP address which node exposes to the redundancy group. 27 | 28 | ### Listener 29 | 30 | ```sh 31 | rgap listener -c /etc/rgap.yaml 32 | ``` 33 | 34 | See also [configuration example](#configuration-example). 35 | 36 | ### PSK Generator 37 | 38 | ```sh 39 | rgap genpsk 40 | ``` 41 | 42 | ## Reference 43 | 44 | ### Listener confiruration 45 | 46 | The file is in YAML syntax with following elements 47 | 48 | * **`listen`** (_list_) 49 | * (_string_) listen port addresses. Accepted formats: _host:port_ or _host:port@interface_ or _host:port@IP/prefixlen_. In later case rgap will find an interface with IP address which belongs to network specified by _IP/prefixlen_. Examples: `239.82.71.65:8271`, `239.82.71.65:8271@eth0`, `239.82.71.65:8271@192.168.0.0/16`. 50 | * **`groups`** (_list_) 51 | * (_dictionary_) 52 | * **`id`** (_uint64_) redundancy group identifier. 53 | * **`psk`** (_string_) hex-encoded pre-shared key for message authentication. 54 | * **`expire`** (_duration_) how long announced address considered active past the timestamp specified in the announcement. 55 | * **`clock_skew`** (_duration_) allowed skew between local clock and time in announcement message. 56 | * **`readiness_delay`** (_duration_) startup delay before group is reported as READY to output plugins. Useful to supress uninitialized group output after startup. 57 | * **`outputs`** (_list_) 58 | * (_dictionary_) 59 | * **`kind`** (_string_) name of output plugin 60 | * **`spec`** (_any_) YAML config of corresponding output plugin 61 | 62 | ### Output plugins reference 63 | 64 | #### `noop` 65 | 66 | Dummy plugin which doesn't do anything. 67 | 68 | #### `log` 69 | 70 | Periodically dumps groups contents to the application log. 71 | 72 | Configuration: 73 | 74 | * **`interval`** (duration) interval between dumps into log. 75 | 76 | #### `eventlog` 77 | 78 | Logs group membership changes to the application log. 79 | 80 | Configuration: 81 | 82 | * **`only_groups`** (_list_ or _null_) list of group identifiers to subscribe to. All groups are logged if this list is `null` or this key is not specified. 83 | * (_uint64_) group ID. 84 | 85 | #### `hostsfile` 86 | 87 | Periodically dumps group contents into hosts file. 88 | 89 | Configuration: 90 | 91 | * **`interval`** (_duration_) interval between periodic dumps. 92 | * **`filename`** (_string_) path to hosts file 93 | * **`mappings`** (_list_) 94 | * (_dictionary_) 95 | * **`group`** (_uint64_) group which addresses should be mapped to given hostname in hosts file 96 | * **`hostname`** (_string_) hostname specified for group addresses in hosts file 97 | * **`fallback_addresses`** (_list_) 98 | * (_string_) addresses to use instead of group addresses if group is empty 99 | * **`prepend_lines`** (_list_) 100 | * (_string_) lines to prepend before output. Useful for comment lines. 101 | * **`append_lines`** (_list_) 102 | * (_string_) lines to append after output. Useful for comment lines. 103 | 104 | #### `dns` 105 | 106 | Runs DNS server responding to queries for names mapped to group addresses. 107 | 108 | Configuration: 109 | 110 | * **`bind_address`** (_string_) 111 | * **`mappings`** (_dictionary_) 112 | * **\*DOMAIN NAME\*** (_dictionary_) 113 | * **`group`** (_uint64_) group ID which addresses whould be returned in response to DNS queries for hostname **\*DOMAIN NAME\***. 114 | * **`fallback_addresses`** (_list_) 115 | * (_string_) addresses to use instead of group addresses if group is empty 116 | * **`compress`** (_boolean_) compress DNS response message 117 | * **`non_authoritative`** (_boolean_) if true, do not set AA bit for DNS response messages 118 | 119 | #### `command` 120 | 121 | Pipes active addresses of group into stdin of external command after each membership change. Redirects stdout and stderr of external command to output into application log. 122 | 123 | Configuration: 124 | 125 | * **`group`** (_uint64_) identifier of group. 126 | * **`command`** (_list of strings_) command and arguments. 127 | * **`timeout`** (_duration_) execution time limit for the command. 128 | * **`retries`** (_int_) attempts to retry failed command. Default is `1`. 129 | * **`wait_delay`** (_duration_) delay to wait for I/O to complete after process termination. Zero value disables I/O cancellation logic. Default is `100ms`. 130 | 131 | ### Configuration example 132 | 133 | ```yaml 134 | listen: 135 | - 239.82.71.65:8271 # or "239.82.71.65:8271@eth0" or "239.82.71.65:8271@192.168.0.0/16" 136 | - 127.0.0.1:8282 137 | 138 | groups: 139 | - id: 1000 140 | psk: 8f1302643b0809279794c5cc47f236561d7442b85d748bd7d1a58adfbe9ff431 141 | expire: 15s 142 | clock_skew: 10s 143 | readiness_delay: 15s 144 | 145 | outputs: 146 | - kind: noop 147 | spec: 148 | - kind: log 149 | spec: 150 | interval: 60s 151 | - kind: eventlog 152 | spec: # or skip spec at all 153 | only_groups: # or specify null for all groups 154 | - 1000 155 | - kind: hostsfile 156 | spec: 157 | interval: 5s 158 | filename: hosts 159 | mappings: 160 | - group: 1000 161 | hostname: worker 162 | fallback_addresses: 163 | - 1.2.3.4 164 | - 5.6.7.8 165 | prepend_lines: 166 | - "# Auto-generated hosts file" 167 | - "# Do not edit manually, changes will be overwritten by RGAP" 168 | append_lines: 169 | - "# End of auto-generated file" 170 | - kind: dns 171 | spec: 172 | bind_address: :8253 173 | mappings: 174 | worker.example.com: 175 | group: 1000 176 | fallback_addresses: 177 | - 1.2.3.4 178 | - 5.6.7.8 179 | worker.example.org: 180 | group: 1000 181 | fallback_addresses: 182 | - 1.2.3.4 183 | - 5.6.7.8 184 | - kind: command 185 | spec: 186 | group: 1000 187 | command: 188 | - "/home/user/sync.sh" 189 | - "--group" 190 | - "1000" 191 | timeout: 5s 192 | retries: 3 193 | 194 | ``` 195 | 196 | ### CLI synopsis 197 | 198 | Run `rgap help` to see details of command line interface. 199 | --------------------------------------------------------------------------------