diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..9e60eb4 --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,61 @@ +name: Build and Test +on: + push: + branches: + - master + pull_request: + +jobs: + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - name: Set up Go + uses: actions/setup-go@v1 + with: + go-version: 1.13 + + - name: Check out code + uses: actions/checkout@v1 + + - name: Lint Go Code + run: | + export PATH=$PATH:$(go env GOPATH)/bin # temporary fix. See https://github.com/actions/setup-go/issues/14 + go get -u golang.org/x/lint/golint + make lint + + test: + name: Test + runs-on: ubuntu-latest + steps: + - name: Set up Go + uses: actions/setup-go@v1 + with: + go-version: 1.13 + + - name: Check out code + uses: actions/checkout@v1 + + - name: Run Unit tests + run: | + export PATH=$PATH:$(go env GOPATH)/bin + make test-coverage + + + build: + name: Build + runs-on: ubuntu-latest + needs: [lint, test] + steps: + - name: Set up Go + uses: actions/setup-go@v1 + with: + go-version: 1.13 + + - name: Check out code + uses: actions/checkout@v1 + + - name: Build Everything + run: | + export PATH=$PATH:$(go env GOPATH)/bin + make build diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..784c618 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,25 @@ +name: Release +on: + create: + tags: + - v* + +jobs: + release: + name: Publish Release + runs-on: ubuntu-latest + steps: + - name: Check out code + uses: actions/checkout@v1 + + - name: Validates GO releaser config + uses: docker://goreleaser/goreleaser:latest + with: + args: check + + - name: Create release on GitHub + uses: docker://goreleaser/goreleaser:latest + with: + args: release + env: + GITHUB_TOKEN: ${{secrets.GORELEASER_GITHUB_TOKEN}} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d76b74e --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +build +.DS_Store diff --git a/.goreleaser.yml b/.goreleaser.yml new file mode 100644 index 0000000..34a8770 --- /dev/null +++ b/.goreleaser.yml @@ -0,0 +1,16 @@ +env: + - GO111MODULE=on +before: + hooks: + - go mod tidy +builds: +- env: + - CGO_ENABLED=0 + goos: + - linux + - darwin + goarch: + - arm + - arm64 + - amd64 + - 386 diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..441af87 --- /dev/null +++ b/Makefile @@ -0,0 +1,45 @@ +PROJECT_NAME := "snmp-mqtt" +PKG := "github.com/dchote/$(PROJECT_NAME)" +PKG_LIST := $(shell go list ${PKG}/... | grep -v /vendor/) +GO_FILES := $(shell find . -name '*.go' | grep -v /vendor/ | grep -v _test.go) + +.PHONY: all dep lint vet test test-coverage build clean + +all: build + +dep: ## Get the dependencies + @echo Installing dependencies + @go mod download + +lint: ## Lint Golang files + @golint -set_exit_status ${PKG_LIST} + +vet: ## Run go vet + @go vet ${PKG_LIST} + +test: ## Run unittests + @go test -short ${PKG_LIST} + +test-coverage: ## Run tests with coverage + @go test -short -coverprofile cover.out -covermode=atomic ${PKG_LIST} + @cat cover.out >> coverage.txt + +build: dep ## Build the binary file + @echo Building native binary + @go build -i -o build/snmp-mqtt $(PKG) + +linux: build + @echo Building Linux binary + @env CC=x86_64-linux-musl-gcc GOOS=linux GOARCH=amd64 CGO_ENABLED=1 go build -ldflags "-linkmode external -extldflags -static" -o build/snmp-mqtt + +raspi: build + @echo Building Rasperry Pi Linux binary + @env GOOS=linux GOARCH=arm GOARM=6 CGO_ENABLED=0 go build -o build/snmp-mqtt + + +clean: ## Remove previous build + @rm -f $(PROJECT_NAME)/build + +help: ## Display this help screen + @grep -h -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' + diff --git a/README.md b/README.md index 6665c46..bc2815a 100644 --- a/README.md +++ b/README.md @@ -1 +1,41 @@ -# snmp-mqtt \ No newline at end of file +# snmp-mqtt + +A simple go app that reads SNMP values and publishes it to the specified MQTT endpoint at the specified interval. + +Please download the precompiled binary from the releases page: https://github.com/dchote/snmp-mqtt/releases + +``` +Usage: snmp-mqtt [options] + +Options: + --endpoints_map= SNMP Endpoints Map File [default: ./endpoints.json] + --server= MQTT server host/IP [default: 127.0.0.1] + --port= MQTT server port [default: 1883] + --topic= MQTT topic prefix [default: snmp] + --clientid= MQTT client identifier [default: snmp] + --interval= Poll interval (seconds) [default: 5] + -h, --help Show this screen. + -v, --version Show version. +``` + +An example endpoints.json file: +``` +{ + "snmpEndpoints": [ + { + "endpoint": "172.18.0.1", + "community": "public", + "oidTopics": [ + { + "oid": ".1.3.6.1.2.1.31.1.1.1.6.4", + "topic": "router/bytesIn" + }, + { + "oid": ".1.3.6.1.2.1.31.1.1.1.10.4", + "topic": "router/bytesOut" + } + ] + } + ] +} +``` \ No newline at end of file diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..d7315f3 --- /dev/null +++ b/config/config.go @@ -0,0 +1,66 @@ +package config + +import ( + "encoding/json" + "os" + "strconv" +) + +type OIDTopicObject struct { + OID string `json:"oid"` + Topic string `json:"topic"` +} + +type SNMPEndpointObject struct { + Endpoint string `json:"endpoint"` + Community string `json:"community"` + OIDTopics []OIDTopicObject `json:"oidTopics"` +} + +// SNMPConfig basic config +type SNMPMapObject struct { + SNMPEndpoints []SNMPEndpointObject `json:"snmpEndpoints"` +} + +var ( + // SNMPMap is the loaded JSON configuration + SNMPMap *SNMPMapObject + + // Server is the MQTT server address + Server string + + // Port is the MQTT server listen port + Port int + + // ClientID is how the name of the client + ClientID string + + // TopicPrefix is just that, a prefix for the presented data keys + TopicPrefix string + + // Interval is the poll interval in seconds + Interval int +) + +// ConnectionString returns the MQTT connection string +func ConnectionString() string { + return "tcp://" + Server + ":" + strconv.Itoa(Port) +} + +// LoadMap loads the file in to the struct +func LoadMap(file string) error { + configFile, err := os.Open(file) + defer configFile.Close() + if err != nil { + return err + } + + jsonParser := json.NewDecoder(configFile) + err = jsonParser.Decode(&SNMPMap) + + if err != nil { + return err + } + + return nil +} diff --git a/endpoints.json b/endpoints.json new file mode 100644 index 0000000..3375f07 --- /dev/null +++ b/endpoints.json @@ -0,0 +1,84 @@ +{ + "snmpEndpoints": [ + { + "endpoint": "172.18.0.1", + "community": "public", + "oidTopics": [ + { + "oid": ".1.3.6.1.2.1.31.1.1.1.6.4", + "topic": "router/bytesIn" + }, + { + "oid": ".1.3.6.1.2.1.31.1.1.1.10.4", + "topic": "router/bytesOut" + } + ] + }, + { + "endpoint": "172.18.0.50", + "community": "public", + "oidTopics": [ + { + "oid": ".1.3.6.1.4.1.318.1.1.12.2.3.1.1.2.1", + "topic": "rackPDU/totalLoad" + }, + { + "oid": ".1.3.6.1.4.1.318.1.1.12.1.16.0", + "topic": "rackPDU/watts" + } + ] + }, + { + "endpoint": "172.18.0.51", + "community": "public", + "oidTopics": [ + { + "oid": ".1.3.6.1.4.1.318.1.1.1.3.2.1.0", + "topic": "rackUPS/lineVoltage" + }, + { + "oid": ".1.3.6.1.4.1.318.1.1.1.4.2.4.0", + "topic": "rackUPS/outputCurrent" + }, + { + "oid": ".1.3.6.1.4.1.318.1.1.1.2.2.1.0", + "topic": "rackUPS/batteryCapacity" + }, + { + "oid": ".1.3.6.1.4.1.318.1.1.1.2.2.3.0", + "topic": "rackUPS/batteryRuntime" + }, + { + "oid": ".1.3.6.1.4.1.318.1.1.1.2.2.2.0", + "topic": "rackUPS/batteryTemperature" + } + ] + }, + { + "endpoint": "172.18.0.52", + "community": "public", + "oidTopics": [ + { + "oid": ".1.3.6.1.4.1.318.1.1.1.3.2.1.0", + "topic": "printerUPS/lineVoltage" + }, + { + "oid": ".1.3.6.1.4.1.318.1.1.1.4.2.4.0", + "topic": "printerUPS/outputCurrent" + }, + { + "oid": ".1.3.6.1.4.1.318.1.1.1.2.2.1.0", + "topic": "printerUPS/batteryCapacity" + }, + { + "oid": ".1.3.6.1.4.1.318.1.1.1.2.2.3.0", + "topic": "printerUPS/batteryRuntime" + }, + { + "oid": ".1.3.6.1.4.1.318.1.1.1.2.2.2.0", + "topic": "printerUPS/batteryTemperature" + } + ] + } + ] +} \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..4fe4942 --- /dev/null +++ b/go.mod @@ -0,0 +1,12 @@ +module github.com/dchote/snmp-mqtt + +go 1.13 + +require ( + github.com/aleasoluciones/goaleasoluciones v0.0.0-20190802084519-19690e2580be // indirect + github.com/aleasoluciones/gosnmpquerier v0.0.0-20190802084245-be620504e4c1 + github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815 // indirect + github.com/eclipse/paho.mqtt.golang v1.2.0 + github.com/soniah/gosnmp v1.22.0 + golang.org/x/net v0.0.0-20191014212845-da9a3fd4c582 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..882532c --- /dev/null +++ b/go.sum @@ -0,0 +1,21 @@ +github.com/aleasoluciones/goaleasoluciones v0.0.0-20190802084519-19690e2580be h1:+8uDL4/VO0/GuAPW0x7l/mGFS6ACAqphmzL/k/69T7s= +github.com/aleasoluciones/goaleasoluciones v0.0.0-20190802084519-19690e2580be/go.mod h1:dPDme2W0OahZUhqg9YFKeTvA7EeuLaT9jXWhiTsNIAE= +github.com/aleasoluciones/gosnmpquerier v0.0.0-20190802084245-be620504e4c1 h1:14GLmz3g2hEyJsEVwd/aQz9GDtmf3/IYNi03twvSWxc= +github.com/aleasoluciones/gosnmpquerier v0.0.0-20190802084245-be620504e4c1/go.mod h1:UAyt3+5oUrjn/x3W1YJT7259PP6NFGokAm1h5h5B9NE= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815 h1:bWDMxwH3px2JBh6AyO7hdCn/PkvCZXii8TGj7sbtEbQ= +github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3ebgob9U8Nd0kOddGdZWjyMGR8Wziv+TBNwSE= +github.com/eclipse/paho.mqtt.golang v1.2.0 h1:1F8mhG9+aO5/xpdtFkW4SxOJB67ukuDC3t2y2qayIX0= +github.com/eclipse/paho.mqtt.golang v1.2.0/go.mod h1:H9keYFcgq3Qr5OUJm/JZI/i6U7joQ8SYLhZwfeOo6Ts= +github.com/golang/mock v1.2.0 h1:28o5sBqPkBsMGnC6b4MvE2TzSr5/AT4c/1fLqVGIwlk= +github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/soniah/gosnmp v1.22.0 h1:jVJi8+OGvR+JHIaZKMmnyNP0akJd2vEgNatybwhZvxg= +github.com/soniah/gosnmp v1.22.0/go.mod h1:DuEpAS0az51+DyVBQwITDsoq4++e3LTNckp2GoasF2I= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/net v0.0.0-20191014212845-da9a3fd4c582 h1:p9xBe/w/OzkeYVKm234g55gMdD1nSIooTir5kV11kfA= +golang.org/x/net v0.0.0-20191014212845-da9a3fd4c582/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= diff --git a/snmp-mqtt.go b/snmp-mqtt.go new file mode 100644 index 0000000..f874f79 --- /dev/null +++ b/snmp-mqtt.go @@ -0,0 +1,86 @@ +package main + +import ( + "log" + "os" + "os/signal" + "syscall" + + "github.com/dchote/snmp-mqtt/config" + "github.com/dchote/snmp-mqtt/snmp" + + "github.com/docopt/docopt-go" +) + +var exitChan = make(chan int) + +// VERSION beause... +const VERSION = "0.0.1" + +func cliArguments() { + usage := ` +Usage: snmp-mqtt [options] + +Options: + --endpoints_map= SNMP Endpoints Map File [default: ./endpoints.json] + --server= MQTT server host/IP [default: 127.0.0.1] + --port= MQTT server port [default: 1883] + --topic= MQTT topic prefix [default: snmp] + --clientid= MQTT client identifier [default: snmp] + --interval= Poll interval (seconds) [default: 5] + -h, --help Show this screen. + -v, --version Show version. +` + args, _ := docopt.ParseArgs(usage, os.Args[1:], VERSION) + + mapFile, _ := args.String("--endpoints_map") + err := config.LoadMap(mapFile) + if err != nil { + log.Println(err) + log.Fatal("error opening " + mapFile) + } + + config.Server, _ = args.String("--server") + config.Port, _ = args.Int("--port") + config.TopicPrefix, _ = args.String("--topic") + config.ClientID, _ = args.String("--clientid") + config.Interval, _ = args.Int("--interval") + + log.Printf("server: %s, port: %d, topic prefix: %s, client identifier: %s, poll interval: %d", config.Server, config.Port, config.TopicPrefix, config.ClientID, config.Interval) +} + +// sigChannelListen basic handlers for inbound signals +func sigChannelListen() { + signalChan := make(chan os.Signal, 1) + code := 0 + + signal.Notify(signalChan, os.Interrupt) + signal.Notify(signalChan, os.Kill) + signal.Notify(signalChan, syscall.SIGTERM) + + select { + case sig := <-signalChan: + log.Printf("Received signal %s. shutting down", sig) + case code = <-exitChan: + switch code { + case 0: + log.Println("Shutting down") + default: + log.Println("*Shutting down") + } + } + + os.Exit(code) +} + +func main() { + cliArguments() + + // catch signals + go sigChannelListen() + + // run sensor poll loop + snmp.Init() + + os.Exit(0) +} diff --git a/snmp/snmp.go b/snmp/snmp.go new file mode 100644 index 0000000..fcaaebc --- /dev/null +++ b/snmp/snmp.go @@ -0,0 +1,98 @@ +package snmp + +import ( + "fmt" + "log" + "strings" + "sync" + "time" + + "github.com/dchote/snmp-mqtt/config" + + "github.com/eclipse/paho.mqtt.golang" + "github.com/soniah/gosnmp" +) + +var () + +// Init contains the generic read/publish loop +func Init() { + opts := mqtt.NewClientOptions().AddBroker(config.ConnectionString()).SetClientID(config.ClientID) + opts.SetKeepAlive(2 * time.Second) + opts.SetPingTimeout(1 * time.Second) + + client := mqtt.NewClient(opts) + if token := client.Connect(); token.Wait() && token.Error() != nil { + panic(token.Error()) + } + + var wg sync.WaitGroup + + for { + wg.Add(1) + + go func() { + defer wg.Done() + + for _, endpoint := range config.SNMPMap.SNMPEndpoints { + log.Println("Polling endpoint " + endpoint.Endpoint) + + snmp := gosnmp.GoSNMP{} + + snmp.Target = endpoint.Endpoint + snmp.Port = 161 + snmp.Version = gosnmp.Version2c + snmp.Community = endpoint.Community + + snmp.Timeout = time.Duration(5 * time.Second) + err := snmp.Connect() + if err != nil { + log.Fatal("SNMP Connect err: %v\n", err) + } + + oids := []string{} + + for _, oidTopic := range endpoint.OIDTopics { + oids = append(oids, oidTopic.OID) + } + + result, err := snmp.Get(oids) + if err != nil { + log.Printf("error in Get: %s", err) + } else { + for _, variable := range result.Variables { + for _, oidTopic := range endpoint.OIDTopics { + if strings.Compare(oidTopic.OID, variable.Name) == 0 { + convertedValue := "" + + switch variable.Type { + case gosnmp.OctetString: + convertedValue = string(variable.Value.([]byte)) + default: + convertedValue = fmt.Sprintf("%d", gosnmp.ToBigInt(variable.Value)) + } + + log.Printf("%s = %s", oidTopic.Topic, convertedValue) + token := client.Publish(config.TopicPrefix+"/"+oidTopic.Topic, 0, false, convertedValue) + + token.Wait() + if token.Error() != nil { + log.Fatal(token.Error()) + } + } + } + } + } + snmp.Conn.Close() + } + + }() + + time.Sleep(time.Duration(config.Interval) * time.Second) + } + + wg.Wait() + + client.Disconnect(250) + time.Sleep(1 * time.Second) +}