Skip to content

Commit 93b6a11

Browse files
committed
Implement execution of commands over MQTT.
1 parent 133a96f commit 93b6a11

File tree

15 files changed

+518
-139
lines changed

15 files changed

+518
-139
lines changed

cmd/lora-gateway-bridge/cmd/configfile.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -347,6 +347,21 @@ marshaler="{{ .Integration.Marshaler }}"
347347
{{ range $k, $v := .MetaData.Dynamic.Commands }}
348348
{{ $k }}="{{ $v }}"
349349
{{ end }}
350+
351+
# Executable commands.
352+
#
353+
# The configured commands can be triggered by sending a message to the
354+
# LoRa Gateway Bridge.
355+
[commands]
356+
# Example:
357+
# [commands.commands.reboot]
358+
# max_execution_duration="1s"
359+
# command="/usr/bin/reboot"
360+
{{ range $k, $v := .Commands.Commands }}
361+
[commands.commands.{{ $k }}]
362+
max_execution_duration="{{ $v.MaxExecutionDuration }}"
363+
command="{{ $v.Command }}"
364+
{{ end }}
350365
`
351366

352367
var configCmd = &cobra.Command{

cmd/lora-gateway-bridge/cmd/root_run.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"github.com/spf13/cobra"
1111

1212
"github.com/brocaar/lora-gateway-bridge/internal/backend"
13+
"github.com/brocaar/lora-gateway-bridge/internal/commands"
1314
"github.com/brocaar/lora-gateway-bridge/internal/config"
1415
"github.com/brocaar/lora-gateway-bridge/internal/filters"
1516
"github.com/brocaar/lora-gateway-bridge/internal/forwarder"
@@ -29,6 +30,7 @@ func run(cmd *cobra.Command, args []string) error {
2930
setupForwarder,
3031
setupMetrics,
3132
setupMetaData,
33+
setupCommands,
3234
}
3335

3436
for _, t := range tasks {
@@ -99,3 +101,10 @@ func setupFilters() error {
99101
}
100102
return nil
101103
}
104+
105+
func setupCommands() error {
106+
if err := commands.Setup(config.C); err != nil {
107+
return errors.Wrap(err, "setup commands error")
108+
}
109+
return nil
110+
}

docs/content/install/config.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -386,6 +386,17 @@ marshaler="protobuf"
386386
[meta_data.dynamic.commands]
387387
# Example:
388388
# temperature="/opt/gateway-temperature/gateway-temperature.sh"
389+
390+
391+
# Executable commands.
392+
#
393+
# The configured commands can be triggered by sending a message to the
394+
# LoRa Gateway Bridge.
395+
[commands]
396+
# Example:
397+
# [commands.commands.reboot]
398+
# max_execution_duration="1s"
399+
# command="/usr/bin/reboot"
389400
{{</highlight>}}
390401

391402
## Environment variables

docs/content/payloads/commands.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,3 +110,26 @@ It holds the gateway internal context (e.g. internal timing information).
110110

111111
This message is defined by the `DownlinkFrame` Protobuf message.
112112

113+
## `exec` - Command execution request
114+
115+
This will request the execution of a command by the LoRa Gateway Bridge. Please
116+
note that these commands must be pre-configured in the [Configuration file]({{<ref "install/config.md">}}).
117+
118+
### JSON
119+
120+
{{<highlight json>}}
121+
{
122+
"gatewayID": "cnb/AC4GLBg=",
123+
"command": "reboot",
124+
"token": "[BASE64 ENCODED BLOB]",
125+
"stdin": "[OPTIONAL BASE64 ENCODED BLOB]",
126+
"environment": {
127+
"ENV_VAR_1": "value1",
128+
"ENV_VAR_2": "value2"
129+
}
130+
}
131+
{{< /highlight >}}
132+
133+
### Protobuf
134+
135+
This message is defined by the `GatewayCommandExecRequest` Protobuf message.

docs/content/payloads/events.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,3 +118,23 @@ Possible error values are:
118118

119119
This message is defined by the `DownlinkTXAck` Protobuf message.
120120

121+
## `exec` - Command execution response
122+
123+
The `exec` event is sent back after an `exec` command and contains the
124+
execution output (or possible error).
125+
126+
### JSON
127+
128+
{{<highlight json>}}
129+
{
130+
"gatewayID": "cnb/AC4GLBg=",
131+
"token": "[BASE64 ENCODED BLOB]",
132+
"stdout": "[BASE64 ENCODED BLOB]",
133+
"stderr": "[BASE64 ENCODED BLOB]",
134+
"error": "optional error message"
135+
}
136+
{{< /highlight >}}
137+
138+
### Protobuf
139+
140+
This message is defined by the `GatewayCommandExecResponse` Protobuf message.

go.mod

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,10 @@ module github.com/brocaar/lora-gateway-bridge
33
go 1.12
44

55
require (
6-
github.com/brocaar/loraserver v0.0.0-20190429120626-75ef246869d5
7-
github.com/brocaar/lorawan v0.0.0-20190402092148-5bca41b178e9
6+
github.com/brocaar/loraserver v0.0.0-20190729122155-2f0bb9c308bc
7+
github.com/brocaar/lorawan v0.0.0-20190709091804-c3a80883a8fa
88
github.com/dgrijalva/jwt-go v3.2.0+incompatible
99
github.com/eclipse/paho.mqtt.golang v1.2.0
10-
github.com/go-kit/kit v0.9.0 // indirect
1110
github.com/golang/protobuf v1.3.2
1211
github.com/goreleaser/goreleaser v0.106.0
1312
github.com/gorilla/websocket v1.4.0
@@ -18,12 +17,7 @@ require (
1817
github.com/sirupsen/logrus v1.4.2
1918
github.com/spf13/cobra v0.0.3
2019
github.com/spf13/viper v1.3.2
21-
github.com/stretchr/objx v0.2.0 // indirect
2220
github.com/stretchr/testify v1.3.0
23-
golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4 // indirect
2421
golang.org/x/lint v0.0.0-20190409202823-959b441ac422
25-
golang.org/x/net v0.0.0-20190628185345-da137c7871d7 // indirect
26-
golang.org/x/sys v0.0.0-20190626221950-04f50cda93cb // indirect
27-
golang.org/x/text v0.3.2 // indirect
2822
golang.org/x/tools v0.0.0-20190709211700-7b25e351ac0e // indirect
2923
)

go.sum

Lines changed: 13 additions & 21 deletions
Large diffs are not rendered by default.

internal/commands/commands.go

Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
package commands
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"io/ioutil"
7+
"os/exec"
8+
"sync"
9+
"time"
10+
11+
"github.com/pkg/errors"
12+
log "github.com/sirupsen/logrus"
13+
14+
"github.com/brocaar/lora-gateway-bridge/internal/config"
15+
"github.com/brocaar/lora-gateway-bridge/internal/integration"
16+
"github.com/brocaar/loraserver/api/gw"
17+
"github.com/brocaar/lorawan"
18+
)
19+
20+
type command struct {
21+
Command string
22+
MaxExecutionDuration time.Duration
23+
}
24+
25+
var (
26+
mux sync.RWMutex
27+
28+
commands map[string]command
29+
)
30+
31+
// Setup configures the gateway commands.
32+
func Setup(conf config.Config) error {
33+
mux.Lock()
34+
defer mux.Unlock()
35+
36+
commands = make(map[string]command)
37+
38+
for k, v := range conf.Commands.Commands {
39+
commands[k] = command{
40+
Command: v.Command,
41+
MaxExecutionDuration: v.MaxExecutionDuration,
42+
}
43+
44+
log.WithFields(log.Fields{
45+
"command": k,
46+
"command_exec": v.Command,
47+
"max_execution_duration": v.MaxExecutionDuration,
48+
}).Info("commands: configuring command")
49+
}
50+
51+
go executeLoop()
52+
53+
return nil
54+
}
55+
56+
func executeLoop() {
57+
for cmd := range integration.GetIntegration().GetGatewayCommandExecRequestChan() {
58+
go func(cmd gw.GatewayCommandExecRequest) {
59+
executeCommand(cmd)
60+
}(cmd)
61+
}
62+
}
63+
64+
func executeCommand(cmd gw.GatewayCommandExecRequest) {
65+
var gatewayID lorawan.EUI64
66+
copy(gatewayID[:], cmd.GatewayId)
67+
68+
stdout, stderr, err := execute(cmd.Command, cmd.Stdin, cmd.Environment)
69+
resp := gw.GatewayCommandExecResponse{
70+
GatewayId: cmd.GatewayId,
71+
Token: cmd.Token,
72+
Stdout: stdout,
73+
Stderr: stderr,
74+
}
75+
if err != nil {
76+
resp.Error = err.Error()
77+
}
78+
79+
if err := integration.GetIntegration().PublishEvent(gatewayID, "exec", &resp); err != nil {
80+
log.WithError(err).Error("commands: publish command execution event error")
81+
}
82+
}
83+
84+
func execute(command string, stdin []byte, environment map[string]string) ([]byte, []byte, error) {
85+
mux.RLock()
86+
defer mux.RUnlock()
87+
88+
cmd, ok := commands[command]
89+
if !ok {
90+
return nil, nil, errors.New("command does not exist")
91+
}
92+
93+
cmdArgs, err := ParseCommandLine(cmd.Command)
94+
if err != nil {
95+
return nil, nil, errors.Wrap(err, "parse command error")
96+
}
97+
if len(cmdArgs) == 0 {
98+
return nil, nil, errors.New("no command is given")
99+
}
100+
101+
log.WithFields(log.Fields{
102+
"command": command,
103+
"exec": cmdArgs[0],
104+
"args": cmdArgs[1:],
105+
"max_execution_duration": cmd.MaxExecutionDuration,
106+
}).Info("commands: executing command")
107+
108+
ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(cmd.MaxExecutionDuration))
109+
defer cancel()
110+
111+
cmdCtx := exec.CommandContext(ctx, cmdArgs[0], cmdArgs[1:]...)
112+
for k, v := range environment {
113+
cmdCtx.Env = append(cmdCtx.Env, fmt.Sprintf("%s=%s", k, v))
114+
}
115+
116+
stdinPipe, err := cmdCtx.StdinPipe()
117+
if err != nil {
118+
return nil, nil, errors.Wrap(err, "get stdin pipe error")
119+
}
120+
121+
stdoutPipe, err := cmdCtx.StdoutPipe()
122+
if err != nil {
123+
return nil, nil, errors.Wrap(err, "get stdout pipe error")
124+
}
125+
126+
stderrPipe, err := cmdCtx.StderrPipe()
127+
if err != nil {
128+
return nil, nil, errors.Wrap(err, "get stderr pipe error")
129+
}
130+
131+
go func() {
132+
defer stdinPipe.Close()
133+
if _, err := stdinPipe.Write(stdin); err != nil {
134+
log.WithError(err).Error("commands: write to stdin error")
135+
}
136+
}()
137+
138+
if err := cmdCtx.Start(); err != nil {
139+
return nil, nil, errors.Wrap(err, "starting command error")
140+
}
141+
142+
stdoutB, _ := ioutil.ReadAll(stdoutPipe)
143+
stderrB, _ := ioutil.ReadAll(stderrPipe)
144+
145+
if err := cmdCtx.Wait(); err != nil {
146+
return nil, nil, errors.Wrap(err, "waiting for command to finish error")
147+
}
148+
149+
return stdoutB, stderrB, nil
150+
}
151+
152+
// source: https://stackoverflow.com/questions/34118732/parse-a-command-line-string-into-flags-and-arguments-in-golang
153+
func ParseCommandLine(command string) ([]string, error) {
154+
var args []string
155+
state := "start"
156+
current := ""
157+
quote := "\""
158+
escapeNext := true
159+
for i := 0; i < len(command); i++ {
160+
c := command[i]
161+
162+
if state == "quotes" {
163+
if string(c) != quote {
164+
current += string(c)
165+
} else {
166+
args = append(args, current)
167+
current = ""
168+
state = "start"
169+
}
170+
continue
171+
}
172+
173+
if escapeNext {
174+
current += string(c)
175+
escapeNext = false
176+
continue
177+
}
178+
179+
if c == '\\' {
180+
escapeNext = true
181+
continue
182+
}
183+
184+
if c == '"' || c == '\'' {
185+
state = "quotes"
186+
quote = string(c)
187+
continue
188+
}
189+
190+
if state == "arg" {
191+
if c == ' ' || c == '\t' {
192+
args = append(args, current)
193+
current = ""
194+
state = "start"
195+
} else {
196+
current += string(c)
197+
}
198+
continue
199+
}
200+
201+
if c != ' ' && c != '\t' {
202+
state = "arg"
203+
current += string(c)
204+
}
205+
}
206+
207+
if state == "quotes" {
208+
return []string{}, errors.New(fmt.Sprintf("Unclosed quote in command line: %s", command))
209+
}
210+
211+
if current != "" {
212+
args = append(args, current)
213+
}
214+
215+
return args, nil
216+
}

0 commit comments

Comments
 (0)