├── .clabot ├── .github └── workflows │ ├── build.yml │ ├── codeql-analysis.yml │ ├── linter.yml │ └── tests.yml ├── .gitignore ├── .golangci.yml ├── API.md ├── LICENSE ├── Makefile ├── README.md ├── arduino-connector.sh ├── auth ├── auth.go └── auth_test.go ├── extra └── main.go ├── go.mod ├── go.sum ├── handlers.go ├── handlers_apt_packages.go ├── handlers_apt_repositories.go ├── handlers_apt_repositories_test.go ├── handlers_containers.go ├── handlers_containers_functional_test.go ├── handlers_containers_integration_test.go ├── handlers_stats.go ├── handlers_test.go ├── handlers_update.go ├── heartbeat.go ├── install.go ├── install_test.go ├── main.go ├── scripts ├── README.md ├── arduino-connector-dev.sh ├── install-tests.sh └── mqtt-tests.sh ├── status.go ├── test ├── .gitignore ├── Vagrantfile ├── create_iot_device.sh ├── go-mosquitto-ubuntu-env ├── playbook.yml ├── private_image │ ├── Dockerfile │ └── run.sh ├── sketch_devops_integ_test │ ├── ReadMe.adoc │ ├── sketch.json │ ├── sketch_devops_integ_test.bin │ ├── sketch_devops_integ_test.bin.sig │ ├── sketch_devops_integ_test.elf │ ├── sketch_devops_integ_test.elf.sig │ ├── sketch_devops_integ_test.ino │ └── sketch_devops_integ_test_malicious │ │ ├── sketch_devops_integ_test.elf │ │ └── sketch_devops_integ_test.elf.sig ├── sketch_env_integ_test │ ├── connector_env_var_test.bin │ ├── connector_env_var_test.bin.sig │ └── connector_env_var_test.ino ├── teardown_dev_artifacts.sh ├── teardown_iot_device.sh └── upload_dev_artifacts_on_s3.sh ├── tests_helper.go ├── updater └── updater.go ├── utils.go └── validate.go /.clabot: -------------------------------------------------------------------------------- 1 | { 2 | "contributors": [] 3 | } 4 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | strategy: 8 | fail-fast: false 9 | matrix: 10 | os: [ubuntu-16.04, ubuntu-18.04, ubuntu-20.04, ubuntu-latest, macos-latest] 11 | arch: [x86, x64] 12 | go: ['1.14', '1.15'] 13 | 14 | runs-on: ${{ matrix.os }} 15 | steps: 16 | 17 | - name: Set up Go 1.x 18 | uses: actions/setup-go@v2 19 | with: 20 | go-version: ${{ matrix.go }} 21 | 22 | - name: Check out code into the Go module directory 23 | uses: actions/checkout@v2 24 | 25 | - name: Build 26 | run: go build 27 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | branches: [master] 8 | 9 | jobs: 10 | analyze: 11 | name: Analyze 12 | runs-on: ubuntu-latest 13 | 14 | strategy: 15 | fail-fast: false 16 | matrix: 17 | language: ['go'] 18 | 19 | steps: 20 | - name: Checkout repository 21 | uses: actions/checkout@v2 22 | 23 | - name: Initialize CodeQL 24 | uses: github/codeql-action/init@v1 25 | with: 26 | languages: ${{ matrix.language }} 27 | 28 | - name: Autobuild 29 | uses: github/codeql-action/autobuild@v1 30 | 31 | - name: Perform CodeQL Analysis 32 | uses: github/codeql-action/analyze@v1 33 | -------------------------------------------------------------------------------- /.github/workflows/linter.yml: -------------------------------------------------------------------------------- 1 | name: lint 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | lint: 7 | name: check with golangci-lint 8 | runs-on: ubuntu-latest 9 | steps: 10 | 11 | - name: check out code 12 | uses: actions/checkout@v2 13 | 14 | - name: golangci-lint 15 | uses: golangci/golangci-lint-action@v1 16 | with: 17 | version: v1.26 -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | Tests: 7 | runs-on: ubuntu-latest 8 | container: guerra1994/go-docker-mqtt-ubuntu-env 9 | 10 | steps: 11 | - name: check out code 12 | uses: actions/checkout@v2 13 | 14 | - name: run mqtt tests 15 | run: ./scripts/mqtt-tests.sh 16 | 17 | - name: run install tests 18 | run: ./scripts/install-tests.sh 19 | 20 | - name: run auth tests 21 | run: go test -v ./auth -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.toptal.com/developers/gitignore/api/go,visualstudiocode,git 2 | # Edit at https://www.toptal.com/developers/gitignore?templates=go,visualstudiocode,git 3 | 4 | ### Git ### 5 | # Created by git for backups. To disable backups in Git: 6 | # $ git config --global mergetool.keepBackup false 7 | *.orig 8 | 9 | # Created by git when using merge tools for conflicts 10 | *.BACKUP.* 11 | *.BASE.* 12 | *.LOCAL.* 13 | *.REMOTE.* 14 | *_BACKUP_*.txt 15 | *_BASE_*.txt 16 | *_LOCAL_*.txt 17 | *_REMOTE_*.txt 18 | 19 | ### Go ### 20 | # Binaries for programs and plugins 21 | *.exe 22 | *.exe~ 23 | *.dll 24 | *.so 25 | *.dylib 26 | 27 | # Test binary, built with `go test -c` 28 | *.test 29 | 30 | # Output of the go coverage tool, specifically when used with LiteIDE 31 | *.out 32 | 33 | # Dependency directories (remove the comment below to include it) 34 | # vendor/ 35 | 36 | ### Go Patch ### 37 | /vendor/ 38 | /Godeps/ 39 | 40 | ### VisualStudioCode ### 41 | .vscode/* 42 | !.vscode/settings.json 43 | !.vscode/tasks.json 44 | !.vscode/launch.json 45 | !.vscode/extensions.json 46 | *.code-workspace 47 | 48 | ### VisualStudioCode Patch ### 49 | # Ignore all local history of files 50 | .history 51 | 52 | 53 | ### Arduino connector file ### 54 | certificate* 55 | arduino-connector 56 | arduino-connector.cfg 57 | arduino-connector-arm 58 | .idea 59 | setup_host_test_env.sh 60 | 61 | ### Remote vscode container 62 | .devcontainer/* 63 | 64 | # End 65 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | linters-settings: 2 | govet: 3 | check-shadowing: true 4 | golint: 5 | min-confidence: 0 6 | gocyclo: 7 | min-complexity: 13 # Should be 10 but was brought to 13 to speed up the development 8 | maligned: 9 | suggest-new: true 10 | dupl: 11 | threshold: 100 12 | goconst: 13 | min-len: 2 14 | min-occurrences: 2 15 | 16 | misspell: 17 | locale: US 18 | 19 | lll: 20 | # max line length, lines longer will be reported. Default is 120. 21 | # '\t' is counted as 1 character by default, and can be changed with the tab-width option 22 | line-length: 130 23 | 24 | # options for analysis running 25 | run: 26 | build-tags: 27 | - functional 28 | 29 | linters: 30 | enable-all: false 31 | disable: 32 | - prealloc 33 | - dupl -------------------------------------------------------------------------------- /API.md: -------------------------------------------------------------------------------- 1 | # API 2 | 3 | To control the arduino-connector you must have: 4 | 5 | - the ID of the device in which the arduino-connector has been installed (eg. `username:0002251d-4e19-4cc8-a4a9-1de215bfb502`) 6 | - a working MQTT connection 7 | 8 | Send messages to the topic ending with /post, receive the answer from the topic ending with /. Errors are sent to the same endpoint. 9 | 10 | You can distinguish between errors and non-errors because of the INFO: or ERROR: prefix of the message 11 | 12 | ### Status 13 | 14 | Retrieve the status of the connector 15 | ``` 16 | {} 17 | --> $aws/things/{{id}}/status/post 18 | 19 | INFO: { 20 | "sketches": { 21 | "4c1f3a9d-ed78-4ae4-94c8-bcfa2e94c692": { 22 | "name":"sketch_oct31a", 23 | "id":"4c1f3a9d-ed78-4ae4-94c8-bcfa2e94c692", 24 | "pid":31343, 25 | "status":"RUNNING", 26 | "endpoints":null 27 | } 28 | } 29 | } 30 | <-- $aws/things/{{id}}/status 31 | ``` 32 | 33 | ### Upload a sketch on the connector 34 | 35 | ``` 36 | { 37 | "token": "toUZDUNTcooVlyqAUwooBGAEtgr8iPzp017RhcST8gM.bDBgrxVzKKySBX-kBPMRqFRqlP3j_cwlgt9qPh_Ct2Y", 38 | "url": "https://api-builder.arduino.cc/builder/v1/compile/sketch_oct31a.bin", 39 | "name": "sketch_oct31a", 40 | "id": "4c1f3a9d-ed78-4ae4-94c8-bcfa2e94c692" 41 | } 42 | --> $aws/things/{{id}}/upload/post 43 | 44 | INFO: Sketch started with PID 570 45 | <-- $aws/things/{{id}}/upload 46 | ``` 47 | 48 | ### Update the arduino-connector (doesn't return anything) 49 | 50 | ``` 51 | { 52 | "url": "https://downloads.arduino.cc/tools/arduino-connector", 53 | "token": "", 54 | "signature: "" 55 | } 56 | --> $aws/things/{{id}}/update/post 57 | 58 | <-- $aws/things/{{id}}/update 59 | ``` 60 | 61 | ### Retrieve the stats of the machine (memory, disk, networks) 62 | 63 | ``` 64 | {} 65 | --> $aws/things/{{id}}/stats/post 66 | 67 | INFO: { 68 | "memory":{ 69 | "FreeMem":1317964, 70 | "TotalMem":15859984, 71 | "AvailableMem":8184204, 72 | "Buffers":757412, 73 | "Cached":6569888, 74 | "FreeSwapMem":0, 75 | "TotalSwapMem":0 76 | }, 77 | "disk":[ 78 | { 79 | "Device":"sysfs", 80 | "Type":"sysfs", 81 | "MountPoint":"/sys", 82 | "FreeSpace":0, 83 | "AvailableSpace":0, 84 | "DiskSize":0 85 | }, 86 | ], 87 | "network":{ 88 | "Devices":[ 89 | { 90 | "AccessPoints":[ 91 | { 92 | "Flags":1, 93 | "Frequency":2437, 94 | "HWAddress":"58:6D:8F:8F:FD:F3", 95 | "MaxBitrate":54000, 96 | "Mode":"Nm80211ModeInfra", 97 | "RSNFlags":392, 98 | "SSID":"ssid-2g", 99 | "Strength":80, 100 | "WPAFlags":0 101 | } 102 | ], 103 | "AvailableConnections":[ 104 | { 105 | "802-11-wireless":{ 106 | "mac-address":"QOIwy+Ef", 107 | "mac-address-blacklist":[], 108 | "mode":"infrastructure", 109 | "security":"802-11-wireless-security", 110 | "seen-bssids":[ 111 | "58:6D:8F:8F:FD:F3" 112 | ], 113 | "ssid":"QkNNSWxhYnMtMmc=" 114 | }, 115 | "802-11-wireless-security":{ 116 | "auth-alg":"open", 117 | "group":[], 118 | "key-mgmt":"wpa-psk", 119 | "pairwise":[], 120 | "proto":[] 121 | }, 122 | "connection":{ 123 | "id":"ssid-2g", 124 | "permissions":[], 125 | "secondaries":[], 126 | "timestamp":1513953989, 127 | "type":"802-11-wireless", 128 | "uuid":"b5dd1024-db02-4e0f-ad3b-c41c375f750a" 129 | }, 130 | "ipv4":{ 131 | "address-data":[], 132 | "addresses":[], 133 | "dns":[], 134 | "dns-search":[], 135 | "method":"auto", 136 | "route-data":[], 137 | "routes":[] 138 | }, 139 | "ipv6":{ 140 | "address-data":[], 141 | "addresses":[], 142 | "dns":[], 143 | "dns-search":[], 144 | "method":"auto", 145 | "route-data":[], 146 | "routes":[] 147 | } 148 | } 149 | ], 150 | "DeviceType":"NmDeviceTypeWifi", 151 | "IP4Config":{ 152 | "Addresses":[ 153 | { 154 | "Address":"10.130.22.132", 155 | "Prefix":24, 156 | "Gateway":"10.130.22.1" 157 | } 158 | ], 159 | "Domains":[], 160 | "Nameservers":[ 161 | "10.130.22.1" 162 | ], 163 | "Routes":[] 164 | }, 165 | "Interface":"wlp4s0", 166 | "State":"NmDeviceStateActivated" 167 | } 168 | ], 169 | "Status":"NmStateConnectedGlobal" 170 | } 171 | } 172 | <-- $aws/things/{{id}}/stats 173 | ``` 174 | 175 | ### Configure the wifi (doesn't return anything) 176 | 177 | ``` 178 | { 179 | "ssid": "ssid-2g", 180 | "password": "passwordssid" 181 | } 182 | --> $aws/things/{{id}}/stats/post 183 | 184 | <-- $aws/things/{{id}}/stats 185 | ``` 186 | 187 | ### Package Management 188 | 189 | #### Retrieve a list of the upgradable packages 190 | 191 | ``` 192 | {} 193 | --> $aws/things/{{id}}/apt/list/post 194 | 195 | INFO: {"packages":[ 196 | {"Name":"firefox","Status":"upgradable","Architecture":"amd64","Version":"57.0.3+build1-0ubuntu0.17.10.1"}, 197 | {"Name":"firefox-locale-en","Status":"upgradable","Architecture":"amd64","Version":"57.0.3+build1-0ubuntu0.17.10.1"} 198 | ], 199 | "page":0,"pages":1} 200 | <-- $aws/things/{{id}}/apt/list 201 | ``` 202 | 203 | #### Get data for a single package 204 | 205 | ``` 206 | {"package": "firmware-linux"} 207 | --> $aws/things/{{id}}/apt/get/post 208 | 209 | INFO: {"packages":[ 210 | {"Name":"firmware-linux","Status":"not-installed","Architecture":"","Version":""} 211 | ]} 212 | <-- $aws/things/{{id}}/apt/get 213 | ``` 214 | 215 | The response is an array that may have 1 element if the package is found or 0 if no packages are found. 216 | 217 | #### Search for installed/installable/upgradable packages 218 | 219 | ``` 220 | {"search": "linux"} 221 | --> $aws/things/{{id}}/apt/list/post 222 | 223 | INFO: {"packages":[ 224 | {"Name":"binutils-x86-64-linux-gnu","Status":"installed","Architecture":"amd64","Version":"2.29.1-4ubuntu1"}, 225 | {"Name":"firmware-linux","Status":"not-installed","Architecture":"","Version":""}, 226 | ... 227 | ],"page":0,"pages":6,"total_items":182} 228 | <-- $aws/things/{{id}}/apt/list 229 | ``` 230 | 231 | Navigate pages 232 | 233 | ``` 234 | {"search": "linux", "page": 2} 235 | --> $aws/things/{{id}}/apt/list/post 236 | 237 | INFO: {"packages":[ 238 | {"Name":"linux-image-4.10.0-30-generic","Status":"config-files","Architecture":"amd64","Version":"4.10.0-30.34"}, 239 | {"Name":"linux-image-4.13.0-21-generic","Status":"installed","Architecture":"amd64","Version":"4.13.0-21.24"}, 240 | ... 241 | ],"page":2,"pages":6} 242 | <-- $aws/things/{{id}}/apt/list 243 | ``` 244 | 245 | #### Update the list of available packages 246 | ``` 247 | {} 248 | --> $aws/things/{{id}}/apt/update/post 249 | 250 | INFO: { 251 | "output" : "apt command output..." 252 | } 253 | <-- $aws/things/{{id}}/apt/update/post 254 | ``` 255 | 256 | #### Install a set of packages 257 | 258 | ``` 259 | {"packages" : { "package-a", "package-b", .... }} 260 | --> $aws/things/{{id}}/apt/install/post 261 | 262 | INFO: { 263 | "output" : "apt command output..." 264 | } 265 | <-- $aws/things/{{id}}/apt/install/post 266 | ``` 267 | 268 | #### Upgrade a set of packages 269 | 270 | ``` 271 | {"packages" : { "package-a", "package-b", .... }} 272 | --> $aws/things/{{id}}/apt/upgrade/post 273 | 274 | INFO: { 275 | "output" : "apt command output..." 276 | } 277 | <-- $aws/things/{{id}}/apt/upgrade/post 278 | ``` 279 | 280 | #### Upgrade all packages 281 | 282 | ``` 283 | {"packages" : { }} 284 | --> $aws/things/{{id}}/apt/upgrade/post 285 | 286 | INFO: { 287 | "output" : "apt command output..." 288 | } 289 | <-- $aws/things/{{id}}/apt/upgrade/post 290 | ``` 291 | 292 | #### Uninstall a set of packages 293 | 294 | ``` 295 | {"packages" : { "package-a", "package-b", .... }} 296 | --> $aws/things/{{id}}/apt/remove/post 297 | 298 | INFO: { 299 | "output" : "apt command output..." 300 | } 301 | <-- $aws/things/{{id}}/apt/remove/post 302 | ``` 303 | 304 | ### Repositories management 305 | 306 | The following API handles repositories, each repository is 307 | repesented by the following JSON structure: 308 | 309 | ``` 310 | { 311 | "enabled": true/false, 312 | "sourceRepo": true/false, 313 | "options": "...", 314 | "uri": "...", 315 | "distribution": "...", 316 | "components": "...", 317 | "comment": "...", 318 | } 319 | ``` 320 | 321 | for clarity, in the following descriptions, we will refer to the above structure with the `REPOSITORYxx` shortcut. 322 | 323 | #### List repositories 324 | 325 | ``` 326 | {} 327 | --> $aws/things/{{id}}/apt/repos/list/post 328 | 329 | INFO: { 330 | REPOSITORY1, 331 | REPOSITORY2, 332 | .... 333 | } 334 | <-- $aws/things/{{id}}/apt/repos/list/post 335 | ``` 336 | 337 | #### Add repository 338 | 339 | ``` 340 | { "repository" : REPOSITORY1 } 341 | --> $aws/things/{{id}}/apt/repos/add/post 342 | 343 | INFO: OK 344 | <-- $aws/things/{{id}}/apt/repos/add/post 345 | ``` 346 | 347 | #### Remove repository 348 | 349 | ``` 350 | { "repository" : REPOSITORY1 } 351 | --> $aws/things/{{id}}/apt/repos/remove/post 352 | 353 | INFO: OK 354 | <-- $aws/things/{{id}}/apt/repos/remove/post 355 | ``` 356 | 357 | #### Edit repository 358 | 359 | The repository in `old_repository` is replaced with `new_repository` 360 | 361 | ``` 362 | { 363 | "old_repository": REPOSITORY1, 364 | "new_repository": REPOSITORY2, 365 | } 366 | --> $aws/things/{{id}}/apt/repos/edit/post 367 | 368 | INFO: OK 369 | <-- $aws/things/{{id}}/apt/repos/edit/post 370 | ``` 371 | 372 | #### Heartbeat 373 | 374 | The connector will send keep-alive messages on the following queue every 15 seconds 375 | 376 | ``` 377 | INFO: 162653.88 378 | <-- $aws/things/{{id}}/heartbeat 379 | ``` 380 | 381 | The number is the uptime in seconds 382 | 383 | ### Containers Management 384 | 385 | #### Containers ps 386 | 387 | implements ```docker ps -a``` and gives back the docker api response transparently 388 | 389 | ``` 390 | {} 391 | --> $aws/things/{{id}}/containers/ps/post 392 | 393 | INFO: [ 394 | { 395 | "Command": "docker-entrypoint.sh redis-server", 396 | "Created": 1527232692, 397 | "HostConfig": { 398 | "NetworkMode": "default" 399 | }, 400 | "Id": "019e3a2f50d24c81a67847f92e23e79f0c0056a210fa4b0d1bf964f9db71680f", 401 | "Image": "docker.io/library/redis", 402 | "ImageID": "sha256:bfcb1f6df2db8a62694aaa732a3133799db59c6fec58bfeda84e34299e7270a8", 403 | "Labels": {}, 404 | "Mounts": [ 405 | { 406 | "Destination": "/data", 407 | "Driver": "local", 408 | "Mode": "", 409 | "Name": "6cb1395830bd65cfac62dd55d4ed19499911191a92a759b2410250608f5df6f0", 410 | "Propagation": "", 411 | "RW": true, 412 | "Source": "", 413 | "Type": "volume" 414 | } 415 | ], 416 | "Names": [ 417 | "/fabrizio-redis" 418 | ], 419 | "NetworkSettings": { 420 | "Networks": { 421 | "bridge": { 422 | "Aliases": null, 423 | "DriverOpts": null, 424 | "EndpointID": "4cc1922ef56f401668ee745d3e819cc21b804dfc119b2c4928e3819175522f66", 425 | "Gateway": "172.17.0.1", 426 | "GlobalIPv6Address": "", 427 | "GlobalIPv6PrefixLen": 0, 428 | "IPAMConfig": null, 429 | "IPAddress": "172.17.0.2", 430 | "IPPrefixLen": 16, 431 | "IPv6Gateway": "", 432 | "Links": null, 433 | "MacAddress": "02:42:ac:11:00:02", 434 | "NetworkID": "8459eb5e58305845527f1e6c737c059f6b409ef581ec8f0b168c1efe67304887" 435 | } 436 | } 437 | }, 438 | "Ports": [ 439 | { 440 | "PrivatePort": 6379, 441 | "Type": "tcp" 442 | } 443 | ], 444 | "State": "running", 445 | "Status": "Up 6 hours" 446 | }, 447 | ... 448 | ] 449 | <-- $aws/things/{{id}}/containers/ps/post 450 | ``` 451 | 452 | is possible also to send a payload with the id of the container to obtain info for one container: 453 | 454 | 455 | ``` 456 | {"id":"019e3a2f50d24c81a67847f92e23e79f0c0056a210fa4b0d1bf964f9db71680f"} 457 | --> $aws/things/{{id}}/containers/ps/post 458 | 459 | INFO: [ 460 | { 461 | "Command": "docker-entrypoint.sh redis-server", 462 | "Created": 1527232692, 463 | "HostConfig": { 464 | "NetworkMode": "default" 465 | }, 466 | "Id": "019e3a2f50d24c81a67847f92e23e79f0c0056a210fa4b0d1bf964f9db71680f", 467 | "Image": "docker.io/library/redis", 468 | "ImageID": "sha256:bfcb1f6df2db8a62694aaa732a3133799db59c6fec58bfeda84e34299e7270a8", 469 | "Labels": {}, 470 | "Mounts": [ 471 | { 472 | "Destination": "/data", 473 | "Driver": "local", 474 | "Mode": "", 475 | "Name": "6cb1395830bd65cfac62dd55d4ed19499911191a92a759b2410250608f5df6f0", 476 | "Propagation": "", 477 | "RW": true, 478 | "Source": "", 479 | "Type": "volume" 480 | } 481 | ], 482 | "Names": [ 483 | "/fabrizio-redis" 484 | ], 485 | "NetworkSettings": { 486 | "Networks": { 487 | "bridge": { 488 | "Aliases": null, 489 | "DriverOpts": null, 490 | "EndpointID": "4cc1922ef56f401668ee745d3e819cc21b804dfc119b2c4928e3819175522f66", 491 | "Gateway": "172.17.0.1", 492 | "GlobalIPv6Address": "", 493 | "GlobalIPv6PrefixLen": 0, 494 | "IPAMConfig": null, 495 | "IPAddress": "172.17.0.2", 496 | "IPPrefixLen": 16, 497 | "IPv6Gateway": "", 498 | "Links": null, 499 | "MacAddress": "02:42:ac:11:00:02", 500 | "NetworkID": "8459eb5e58305845527f1e6c737c059f6b409ef581ec8f0b168c1efe67304887" 501 | } 502 | } 503 | }, 504 | "Ports": [ 505 | { 506 | "PrivatePort": 6379, 507 | "Type": "tcp" 508 | } 509 | ], 510 | "State": "running", 511 | "Status": "Up 6 hours" 512 | }, 513 | ... 514 | ] 515 | <-- $aws/things/{{id}}/containers/ps/post 516 | ``` 517 | 518 | #### Containers Images 519 | 520 | implements ```docker images``` and gives back the docker api response transparently 521 | 522 | ``` 523 | {} 524 | --> $aws/things/{{id}}/containers/images/post 525 | 526 | INFO: [ 527 | { 528 | "Containers": -1, 529 | "Created": 1527112010, 530 | "Id": "sha256:316536b3f5c4aa1102f4ca80282c4d65b6f8da3a267489887b9d837660c7b19b", 531 | "Labels": null, 532 | "ParentId": "", 533 | "RepoDigests": [ 534 | "postgres@sha256:db7a4b960bfbe98ad94799f4a00a4203284c9804177ba317e1f9829fa1237632" 535 | ], 536 | "RepoTags": [ 537 | "postgres:latest" 538 | ], 539 | "SharedSize": -1, 540 | "Size": 235337390, 541 | "VirtualSize": 235337390 542 | }, 543 | ... 544 | ] 545 | <-- $aws/things/{{id}}/containers/images/post 546 | ``` 547 | 548 | 549 | #### Containers Action 550 | 551 | ```docker run ``` 552 | 553 | ``` 554 | { 555 | "action": "run", 556 | "background": true, 557 | "image": "redis", 558 | "name": "my-redis-container" 559 | } 560 | --> $aws/things/{{id}}/containers/action/post 561 | 562 | INFO: [ 563 | { 564 | "action": "run", 565 | "background": true, 566 | "id": "316536b3f5c4aa1102f4ca80282c4d65b6f8da3a267489887b9d837660c7b19b", 567 | "image": "redis", 568 | "name": "my-redis-container" 569 | } 570 | ] 571 | <-- $aws/things/{{id}}/containers/action/post 572 | ``` 573 | 574 | 575 | ```docker start ``` 576 | 577 | ``` 578 | { 579 | "action": "start", 580 | "background": true, 581 | "id": "316536b3f5c4aa1102f4ca80282c4d65b6f8da3a267489887b9d837660c7b19b", 582 | } 583 | --> $aws/things/{{id}}/containers/action/post 584 | 585 | INFO: [ 586 | { 587 | "action": "start", 588 | "background": true, 589 | "id": "316536b3f5c4aa1102f4ca80282c4d65b6f8da3a267489887b9d837660c7b19b", 590 | "image": "redis", 591 | "name": "my-redis-container" 592 | } 593 | ] 594 | <-- $aws/things/{{id}}/containers/action/post 595 | ``` 596 | 597 | ```docker stop ``` 598 | 599 | ``` 600 | { 601 | "action": "stop", 602 | "id": "316536b3f5c4aa1102f4ca80282c4d65b6f8da3a267489887b9d837660c7b19b", 603 | } 604 | --> $aws/things/{{id}}/containers/action/post 605 | 606 | INFO: [ 607 | { 608 | "action": "stop", 609 | "background": true, 610 | "id": "316536b3f5c4aa1102f4ca80282c4d65b6f8da3a267489887b9d837660c7b19b", 611 | "image": "redis", 612 | "name": "my-redis-container" 613 | } 614 | ] 615 | <-- $aws/things/{{id}}/containers/action/post 616 | ``` 617 | 618 | ```docker remove ``` and ```docker image prune -a``` 619 | ``` 620 | { 621 | "action": "remove", 622 | "id": "316536b3f5c4aa1102f4ca80282c4d65b6f8da3a267489887b9d837660c7b19b", 623 | } 624 | --> $aws/things/{{id}}/containers/action/post 625 | 626 | INFO: [ 627 | { 628 | "action": "remove", 629 | "background": true, 630 | "id": "316536b3f5c4aa1102f4ca80282c4d65b6f8da3a267489887b9d837660c7b19b", 631 | "image": "redis", 632 | "name": "my-redis-container" 633 | } 634 | ] 635 | <-- $aws/things/{{id}}/containers/action/post 636 | ``` 637 | 638 | #### Containers rename 639 | 640 | implements ```docker rename CONTAINER NEW_NAME``` 641 | 642 | ``` 643 | {"id": "019e3a2f50d24c81a67847f92e23e79f0c0056a210fa4b0d1bf964f9db71680f","name":"mango"} 644 | --> $aws/things/{{id}}/containers/rename/post 645 | 646 | INFO: [{"id": "019e3a2f50d24c81a67847f92e23e79f0c0056a210fa4b0d1bf964f9db71680f","name":"mango"}] 647 | 648 | <-- $aws/things/{{id}}/containers/rename/post 649 | ``` 650 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # need to use bash in order to use source command 2 | SHELL := /bin/bash 3 | 4 | # Go parameters 5 | GOCMD=go 6 | GOBUILD=$(GOCMD) build 7 | GOCLEAN=$(GOCMD) clean 8 | GOTEST=$(GOCMD) test 9 | GOTEST_TIMEOUT=20m 10 | GOGET=$(GOCMD) get 11 | 12 | 13 | .PHONY: all test clean 14 | 15 | all: test build 16 | 17 | build: 18 | $(GOBUILD) -ldflags "-X main.version=1.0.0-dev" github.com/arduino/arduino-connector 19 | 20 | test: setup-test integ-test teardown-test 21 | 22 | setup-test: 23 | cd ./test && vagrant up --no-provision 24 | cd ./test && ./create_iot_device.sh 25 | 26 | integ-test: 27 | $(GOBUILD) -ldflags "-X main.version=1.0.0-dev" github.com/arduino/arduino-connector 28 | cd ./test && ./upload_dev_artifacts_on_s3.sh 29 | cd ./test && vagrant provision 30 | source ./test/setup_host_test_env.sh && $(GOTEST) --tags=integration ./... -timeout $(GOTEST_TIMEOUT) 31 | 32 | teardown-test: 33 | cd ./test && ./teardown_iot_device.sh 34 | cd ./test && vagrant destroy --force 35 | cd ./test && ./teardown_dev_artifacts.sh 36 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Arduino Connector 2 | 3 | [![Go Report Card](https://goreportcard.com/badge/github.com/arduino/arduino-connector)](https://goreportcard.com/report/github.com/arduino/arduino-connector) 4 | ![Build](https://github.com/arduino/arduino-connector/workflows/Build/badge.svg) 5 | ![lint](https://github.com/arduino/arduino-connector/workflows/lint/badge.svg) 6 | ![Tests](https://github.com/arduino/arduino-connector/workflows/Tests/badge.svg) 7 | 8 | The Arduino Connector allows your device to connect to the Arduino Cloud, and push and receive messages through the [MQTT protocol](http://mqtt.org/). You can see and control all your cloud-enabled devices via a web app called [My Devices](https://create.arduino.cc/devices). 9 | 10 | ## How does it work? 11 | 12 | The Arduino Connector gets installed on a device and does the following things: 13 | 14 | - Connects to MQTT using the certificate and key generated during installation 15 | - Starts and Stops sketches according to the received commands from MQTT 16 | - Collects the output of the sketches in order to send them on MQTT 17 | 18 | ## Install 19 | 20 | Follow the ["Getting Started"](https://create.arduino.cc/getting-started/) guides to install the connector and allow your devices to communicate with the cloud via Arduino Create. You can install the connector onto a [Up2 board](https://create.arduino.cc/getting-started/up2) or a generic [Intel-based platform running Linux](https://create.arduino.cc/getting-started/intel-platforms). 21 | 22 | Make sure you have an Arduino Account and are able to [log in](https://auth.arduino.cc/login). 23 | 24 | Please write us at auth@arduino.cc if you encounter any issue logging in and you need support. 25 | 26 | ## Development for Intel-based platform 27 | 28 | - Download Vagrant on you pc (https://www.vagrantup.com/) 29 | - Clone this repository and `cd ` 30 | - `vagrant init debian/jessie64` 31 | - `vagrant plugin install vagrant-vbguest` 32 | - `vagrant up` 33 | - `vagrant ssh` 34 | - `sudo apt update` 35 | - `sudo apt upgrade` 36 | - `sudo apt-get autoremove` 37 | - `exit` 38 | - `vagrant halt` 39 | - Add inside a Vagrantfile the following line `config.vm.synced_folder "./", "/vagrant_data"` 40 | - `vagrant up` 41 | 42 | Ok now we have a vagrant machine debian based where install and develop arduino-connector. 43 | Inside the machine follow the getting-started guide from previus link and you should be able to see on your dashboard the data of vagrant machine. 44 | 45 | ### Develop workflow 46 | 47 | - Probably arduino-connector will be installed in `home/vagrant` folder. 48 | - Check status of service with `sudo systemctl status ArduinoConnector.service` 49 | - Stop it `sudo systemctl stop ArduinoConnector.service` 50 | - From host machine build new binary in project folder (`go build -ldflags "-X main.version=$VERSION"`) 51 | - Move binary from `/vagrant_data` (shared folder of repository and vagrant machine) to `/home/vagrant` 52 | - Start service `sudo systemctl start ArduinoConnector.service` 53 | - Check your changes (show logs with ``) 54 | 55 | ## Build for ARM devices 56 | 57 | ```bash 58 | GOOS=linux GOARCH=arm go build -ldflags "-X main.version=arm-dev" -o=arduino-connector-arm github.com/arduino/arduino-connector 59 | ``` 60 | 61 | ## Autoupdate 62 | ``` 63 | go get github.com/sanbornm/go-selfupdate 64 | ./bin/go-selfupdate arduino-connector $VERSION 65 | # scp -r public/* user@server:/var/www/files/arduino-connector 66 | ``` 67 | 68 | ## API documentation 69 | 70 | See [API](./API.md) 71 | 72 | ## Functional tests 73 | 74 | These tests can be executed locally. To do that, you need to configure a dedicated docker container: 75 | - get the image `docker pull guerra1994/go-docker-mqtt-ubuntu-env` 76 | - enter the container `docker run -it -v $(pwd):/home --privileged --name gmd guerra1994/go-docker-mqtt-ubuntu-env` 77 | - run the mosquitto MQTT broker in background mode `mosquitto > /dev/null 2>&1 &` 78 | - then run your test, for example `go test -v --tags=functional --run="TestDockerPsApi"` 79 | 80 | 81 | ## Integration tests disclaimer 82 | 83 | You will see in the following paragraphs that the testing environment and procedures are strictly coupled with the 84 | Arduino web services. We're sorry of this behaviour because is not so "community friendly" but we are aiming to improve 85 | both the quality of the connector code and its testing process. Obviously no code quality improvement is possible without 86 | the safety net that tests provide :). So please be patient while we improve the whole process. 87 | 88 | ## Generate temporary installer script 89 | ``` 90 | aws-google-auth -p arduino 91 | go build -ldflags "-X main.version=2.0.22" github.com/arduino/arduino-connector 92 | aws --profile arduino s3 cp arduino-connector-dev.sh s3://arduino-tmp/arduino-connector.sh 93 | aws s3 presign --profile arduino s3://arduino-tmp/arduino-connector.sh --expires-in $(expr 3600 \* 24) 94 | #use this link i the wget of the getting started script 95 | aws --profile arduino s3 cp arduino-connector s3://arduino-tmp/ 96 | aws s3 presign --profile arduino s3://arduino-tmp/arduino-connector --expires-in $(expr 3600 \* 24) 97 | # use the output as the argument of arduino-connector-dev.sh qhen launching getting started script: 98 | 99 | export id=containtel:a4ae70c4-b7ff-40c8-83c1-1e10ee166241 100 | wget -O install.sh 101 | chmod +x install.sh 102 | ./install.sh 103 | 104 | ``` 105 | 106 | i.e 107 | ``` 108 | export id=containtel:a4ae70c4-b7ff-40c8-83c1-1e10ee166241 109 | wget -O install.sh "https://arduino-tmp.s3.amazonaws.com/arduino-connector.sh?AWSAccessKeyId=ASIAJJFZDTIGHJCWMGQA&Expires=1529771794&x-amz-security-token=FQoDYXdzEBoaDD8duZwY18MeYFd3CyLPAjxH7ijRrTBwduS9r8Dqm06%2BT%2B6p57cOU4I1Bn3d09lMVjPi4dhNQboAxLnYSI%2BNqxUo%2BbgNDxRbIVxzgvGWQHw7Seepjniy%2FvCKpR7DuxyNe%2B5DxA15O1fGZDQkqadxlky5jkXk1Vn9TBtGa4NCRMgIoatRBtkHI7XKpouWNYhh2jYo7ezeDRQO3m1WR7WieqVlh%2BdscL0NevGGMOh3MYf5Wsm069GuA31FmTslp3SaChf7Mq7uOI5X9XIu%2B9kcWnxXoo7dMCk5Ixq5WLkB%2BUlTt6iL4bxK7FKdlT%2FUsf5DSfBcCGwcyI2nBuFB6yjPeS5AAm0ZUU6DaEd9KUc8Fxq9M1tEQ3DnjGnKZcbaOU%2FGWw7bnOPhLcl6eiNIOtZxsvZ4MCTY3YUnO4rna4fVNScjIqMwNdb8psFarGH1Gn0e4DRNt22LFshjGZdNi01RKI%2BFqtkF&Signature=jI00Smxp33Y72ijdRJsXMIYx9h0%3D" 110 | chmod +x install.sh 111 | ./install.sh "https://arduino-tmp.s3.amazonaws.com/arduino-connector?AWSAccessKeyId=ASIAJJFZDTIGHJCWMGQA&Expires=1529771799&x-amz-security-token=FQoDYXdzEBoaDD8duZwY18MeYFd3CyLPAjxH7ijRrTBwduS9r8Dqm06%2BT%2B6p57cOU4I1Bn3d09lMVjPi4dhNQboAxLnYSI%2BNqxUo%2BbgNDxRbIVxzgvGWQHw7Seepjniy%2FvCKpR7DuxyNe%2B5DxA15O1fGZDQkqadxlky5jkXk1Vn9TBtGa4NCRMgIoatRBtkHI7XKpouWNYhh2jYo7ezeDRQO3m1WR7WieqVlh%2BdscL0NevGGMOh3MYf5Wsm069GuA31FmTslp3SaChf7Mq7uOI5X9XIu%2B9kcWnxXoo7dMCk5Ixq5WLkB%2BUlTt6iL4bxK7FKdlT%2FUsf5DSfBcCGwcyI2nBuFB6yjPeS5AAm0ZUU6DaEd9KUc8Fxq9M1tEQ3DnjGnKZcbaOU%2FGWw7bnOPhLcl6eiNIOtZxsvZ4MCTY3YUnO4rna4fVNScjIqMwNdb8psFarGH1Gn0e4DRNt22LFshjGZdNi01RKI%2BFqtkF&Signature=BTsZzRhHnf%2Fl%2BWsXfJ9MB1ir318%3D" 112 | 113 | ``` 114 | 115 | ## run integration tests with vagrant 116 | please note that: 117 | * the thing `devops-test:c4d6adc7-a2ca-43ec-9ea6-20568bf407fc` 118 | * the iot IAM policy `DevicePolicy` 119 | * the arduino user `devops-test` 120 | * the s3 bucket `arduino-tmp` 121 | * the test sketch `sketch_devops_integ_test` 122 | * the private image `private_image` 123 | are resources that must be manually created in the Arduino Cloud environment, in order to replicate the testing, you will need to create those resources on your environment and edit the test setup/teardown scripts: 124 | * `upload_dev_artifacts_on_s3.sh` 125 | * `create_iot_device.sh` 126 | * `teardown_dev_artifacts.sh` 127 | * `teardown_iot_device.sh` 128 | 129 | In order to launch the integration test in a CI fashion do the following: 130 | 1. install vagrant from upstream link https://www.vagrantup.com/downloads.html 131 | 2. export the arduino user credentials 132 | 133 | ``` 134 | export CONNECTOR_USER=aaaaaaaa 135 | export CONNECTOR_PASS="bbbbbb" 136 | export CONNECTOR_PRIV_USER="cccccc" 137 | export CONNECTOR_PRIV_PASS="ddddd" 138 | export CONNECTOR_PRIV_IMAGE="/" 139 | ``` 140 | 141 | 3. launch `make test` 142 | 4. profit 143 | 144 | the `test` recipe: 145 | 1. spins up a ubuntu machine 146 | 2. installing your local s3 artifact after uploading it to s3 (to emulate the user install) 147 | 3. creates certs and keys on aws iot in order to talk with the connector instance in the vagrant vm 148 | 4. launch gotests (that basically do mqtt command -> vagrant ssh to check the result in the vm) 149 | 5. teardowns the aws iot things and perform all generated code and vm cleaning up 150 | this recipe has the purpose to be used in a CI/CD context 151 | 152 | The `test` recipe is split in 3 parts (`setup-test integ-test teardown-test`) that can be used separately to do TDD in this way: 153 | 1. launch `make setup-test` 154 | 2. write test and code 155 | 3. export the arduino user credentials 156 | 157 | ``` 158 | export CONNECTOR_USER=aaaaaaaa 159 | export CONNECTOR_PASS="bbbbbb" 160 | export CONNECTOR_PRIV_USER="cccccc" 161 | export CONNECTOR_PRIV_PASS="ddddd" 162 | export CONNECTOR_PRIV_IMAGE="/" 163 | ``` 164 | 165 | 4. launch `make integ-test` all the times you need 166 | 5. launch `make teardown-test` when finished 167 | -------------------------------------------------------------------------------- /arduino-connector.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | # 4 | # This file is part of arduino-connector 5 | # 6 | # Copyright (C) 2017-2020 Arduino AG (http://www.arduino.cc/) 7 | # 8 | # Licensed under the Apache License, Version 2.0 (the "License"); 9 | # you may not use this file except in compliance with the License. 10 | # You may obtain a copy of the License at 11 | # 12 | # http://www.apache.org/licenses/LICENSE-2.0 13 | # 14 | # Unless required by applicable law or agreed to in writing, software 15 | # distributed under the License is distributed on an "AS IS" BASIS, 16 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 17 | # See the License for the specific language governing permissions and 18 | # limitations under the License. 19 | # 20 | 21 | architecture=$(uname -m) 22 | if [ "$architecture" == "x86_64" ] 23 | then 24 | architecture="" 25 | else 26 | architecture="-arm" 27 | fi 28 | 29 | has() { 30 | type "$1" > /dev/null 2>&1 31 | return $? 32 | } 33 | 34 | download() { 35 | if has "wget"; then 36 | wget -nc $1 37 | elif has "curl"; then 38 | curl -SOL $1 39 | else 40 | echo "Error: you need curl or wget to proceed" >&2; 41 | exit 20 42 | fi 43 | } 44 | 45 | # Replicate env variables in uppercase format 46 | export ID=$id 47 | export TOKEN=$token 48 | export HTTP_PROXY=$http_proxy 49 | export HTTPS_PROXY=$https_proxy 50 | export ALL_PROXY=$all_proxy 51 | 52 | echo printenv 53 | echo --------- 54 | 55 | cd $HOME 56 | echo home folder 57 | echo --------- 58 | 59 | echo remove old files 60 | echo --------- 61 | sudo rm -f /usr/bin/arduino-connector* 62 | sudo rm -rf /etc/arduino-connector 63 | 64 | echo uninstall previous installations of connector 65 | echo --------- 66 | if [ "$password" == "" ] 67 | then 68 | sudo systemctl stop ArduinoConnector || true 69 | else 70 | echo $password | sudo -kS systemctl stop ArduinoConnector || true 71 | fi 72 | 73 | if [ "$password" == "" ] 74 | then 75 | sudo rm -f /etc/systemd/system/ArduinoConnector.service 76 | else 77 | echo $password | sudo -kS rm -f /etc/systemd/system/ArduinoConnector.service 78 | fi 79 | 80 | echo download connector 81 | echo --------- 82 | download https://downloads.arduino.cc/tools/feed/arduino-connector/arduino-connector$architecture 83 | sudo mv arduino-connector$architecture /usr/bin/arduino-connector 84 | sudo chmod +x /usr/bin/arduino-connector 85 | 86 | echo install connector 87 | echo --------- 88 | if [ "$password" == "" ] 89 | then 90 | sudo -E arduino-connector -register -install 91 | else 92 | echo $password | sudo -kS -E arduino-connector -register -install > arduino-connector.log 2>&1 93 | fi 94 | 95 | echo start connector service 96 | echo --------- 97 | if [ "$password" == "" ] 98 | then 99 | sudo systemctl start ArduinoConnector 100 | else 101 | echo $password | sudo -kS systemctl start ArduinoConnector 102 | fi 103 | -------------------------------------------------------------------------------- /auth/auth.go: -------------------------------------------------------------------------------- 1 | // 2 | // This file is part of arduino-connector 3 | // 4 | // Copyright (C) 2017-2018 Arduino AG (http://www.arduino.cc/) 5 | // 6 | // Licensed under the Apache License, Version 2.0 (the "License"); 7 | // you may not use this file except in compliance with the License. 8 | // You may obtain a copy of the License at 9 | // 10 | // http://www.apache.org/licenses/LICENSE-2.0 11 | // 12 | // Unless required by applicable law or agreed to in writing, software 13 | // distributed under the License is distributed on an "AS IS" BASIS, 14 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | // See the License for the specific language governing permissions and 16 | // limitations under the License. 17 | // 18 | 19 | // Package auth uses the `oauth2 authorization_code` flow to authenticate with Arduino 20 | // 21 | // If you have the username and password of a user, you can just instantiate a client with sane defaults: 22 | // 23 | // client := auth.New() 24 | // 25 | // and then call the Token method to obtain a Token object with an AccessToken and a RefreshToken 26 | // 27 | // token, err := client.Token(username, password) 28 | // 29 | // If instead you already have a token but want to refresh it, just call 30 | // 31 | // token, err := client.refresh(refreshToken) 32 | package auth 33 | 34 | import ( 35 | "encoding/json" 36 | "io/ioutil" 37 | "math/rand" 38 | "net/http" 39 | "net/url" 40 | "regexp" 41 | "strings" 42 | "time" 43 | 44 | "github.com/pkg/errors" 45 | ) 46 | 47 | type DeviceCode struct { 48 | DeviceCode string `json:"device_code"` 49 | UserCode string `json:"user_code"` 50 | VerificationURI string `json:"verification_uri"` 51 | ExpiresIn int `json:"expires_in"` 52 | Interval int `json:"interval"` 53 | VerificationURIComplete string `json:"verification_uri_complete"` 54 | } 55 | 56 | // HTTPClient interface 57 | type HTTPClient interface { 58 | Do(req *http.Request) (*http.Response, error) 59 | Get(url string) (resp *http.Response, err error) 60 | } 61 | 62 | var client HTTPClient 63 | 64 | // Init initialize correctly HTTP client 65 | func Init() { 66 | client = &http.Client{} 67 | } 68 | 69 | // StartDeviceAuth request with POST auth using clientID 70 | func StartDeviceAuth(authURL, clientID string) (data DeviceCode, err error) { 71 | url := authURL + "/oauth/device/code" 72 | 73 | payload := strings.NewReader("client_id=" + clientID + "&audience=https://api.arduino.cc") 74 | 75 | req, err := http.NewRequest("POST", url, payload) 76 | if err != nil { 77 | return data, err 78 | } 79 | 80 | req.Header.Add("content-type", "application/x-www-form-urlencoded") 81 | 82 | res, err := client.Do(req) 83 | if err != nil { 84 | return data, err 85 | } 86 | 87 | defer res.Body.Close() 88 | body, err := ioutil.ReadAll(res.Body) 89 | if err != nil { 90 | return data, err 91 | } 92 | 93 | err = json.Unmarshal(body, &data) 94 | if err != nil { 95 | return data, err 96 | } 97 | 98 | return data, nil 99 | } 100 | 101 | func CheckDeviceAuth(authURL, clientID, deviceCode string) (token string, err error) { 102 | url := authURL + "/oauth/token" 103 | 104 | payload := strings.NewReader("grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code&device_code=" + deviceCode + "&client_id=" + clientID) 105 | 106 | req, err := http.NewRequest("POST", url, payload) 107 | if err != nil { 108 | return token, err 109 | } 110 | 111 | req.Header.Add("content-type", "application/x-www-form-urlencoded") 112 | 113 | res, err := client.Do(req) 114 | if err != nil { 115 | return token, err 116 | } 117 | 118 | defer res.Body.Close() 119 | body, err := ioutil.ReadAll(res.Body) 120 | if err != nil { 121 | return token, err 122 | } 123 | 124 | if res.StatusCode != 200 { 125 | return token, errors.New(string(body)) 126 | } 127 | 128 | data := struct { 129 | AccessToken string `json:"access_token"` 130 | ExpiresIn int `json:"expires_in"` 131 | TokenType string `json:"token_type"` 132 | }{} 133 | 134 | err = json.Unmarshal(body, &data) 135 | if err != nil { 136 | return token, err 137 | } 138 | 139 | return data.AccessToken, nil 140 | } 141 | 142 | // Config contains the variables you may want to change 143 | type Config struct { 144 | // CodeURL is the endpoint to redirect to obtain a code 145 | CodeURL string 146 | 147 | // TokenURL is the endpoint where you can request an access code 148 | TokenURL string 149 | 150 | // ClientID is the client id you are using 151 | ClientID string 152 | 153 | // RedirectURI is the redirectURI where the oauth process will redirect. It's only required since the oauth system checks for it, but we intercept the redirect before hitting it 154 | RedirectURI string 155 | 156 | // Scopes is a space-separated list of scopes to require 157 | Scopes string 158 | } 159 | 160 | // New returns an auth configuration with sane defaults 161 | func New() *Config { 162 | return &Config{ 163 | CodeURL: "https://hydra.arduino.cc/oauth2/auth", 164 | TokenURL: "https://hydra.arduino.cc/oauth2/token", 165 | ClientID: "cli", 166 | RedirectURI: "http://localhost:5000", 167 | Scopes: "profile:core offline", 168 | } 169 | } 170 | 171 | // Token is the response of the two authentication functions 172 | type Token struct { 173 | // Access is the token to use to authenticate requests 174 | Access string `json:"access_token"` 175 | 176 | // Refresh is the token to use to request another access token. It's only returned if one of the scopes is "offline" 177 | Refresh string `json:"refresh_token"` 178 | 179 | // TTL is the number of seconds that the tokens will last 180 | TTL int `json:"expires_in"` 181 | 182 | // Scopes is a space-separated list of scopes associated to the access token 183 | Scopes string `json:"scope"` 184 | 185 | // Type is the type of token 186 | Type string `json:"token_type"` 187 | } 188 | 189 | // Token authenticates with the given username and password and returns a Token object 190 | func (c *Config) Token(user, pass string) (*Token, error) { 191 | // We want to make sure we send the proper cookies each step, so we don't follow redirects 192 | client := &http.Client{ 193 | CheckRedirect: func(req *http.Request, via []*http.Request) error { 194 | return http.ErrUseLastResponse 195 | }, 196 | } 197 | 198 | // Request authentication page 199 | url, cookies, err := c.requestAuth(client) 200 | if err != nil { 201 | return nil, errors.Wrap(err, "get the auth page") 202 | } 203 | 204 | // Authenticate 205 | code, err := c.authenticate(client, cookies, url, user, pass) 206 | if err != nil { 207 | return nil, errors.Wrap(err, "authenticate") 208 | } 209 | 210 | // Request token 211 | token, err := c.requestToken(client, code) 212 | if err != nil { 213 | return nil, errors.Wrap(err, "request token") 214 | } 215 | return token, nil 216 | } 217 | 218 | // Refresh exchanges a token for a new one 219 | func (c *Config) Refresh(token string) (*Token, error) { 220 | client := http.Client{} 221 | query := url.Values{} 222 | query.Add("refresh_token", token) 223 | query.Add("client_id", c.ClientID) 224 | query.Add("redirect_uri", c.RedirectURI) 225 | query.Add("grant_type", "refresh_token") 226 | 227 | req, err := http.NewRequest("POST", c.TokenURL, strings.NewReader(query.Encode())) 228 | if err != nil { 229 | return nil, err 230 | } 231 | 232 | req.Header.Add("Content-Type", "application/x-www-form-urlencoded") 233 | req.SetBasicAuth("cli", "") 234 | res, err := client.Do(req) 235 | if err != nil { 236 | return nil, err 237 | } 238 | 239 | body, err := ioutil.ReadAll(res.Body) 240 | if err != nil { 241 | return nil, err 242 | } 243 | 244 | data := Token{} 245 | 246 | err = json.Unmarshal(body, &data) 247 | if err != nil { 248 | return nil, err 249 | } 250 | return &data, nil 251 | } 252 | 253 | // cookies keeps track of the cookies for each request 254 | type cookies map[string][]*http.Cookie 255 | 256 | // requestAuth calls hydra and follows the redirects until it reaches the authentication page. It saves the cookie it finds so it can apply them to subsequent requests 257 | func (c *Config) requestAuth(client HTTPClient) (string, cookies, error) { 258 | uri, err := url.Parse(c.CodeURL) 259 | if err != nil { 260 | return "", nil, err 261 | } 262 | 263 | query := uri.Query() 264 | query.Add("client_id", c.ClientID) 265 | query.Add("state", randomString(8)) 266 | query.Add("scope", c.Scopes) 267 | query.Add("response_type", "code") 268 | query.Add("redirect_uri", c.RedirectURI) 269 | uri.RawQuery = query.Encode() 270 | 271 | // Navigate to hydra request page 272 | res, err := client.Get(uri.String()) 273 | if err != nil { 274 | return "", nil, err 275 | } 276 | 277 | cs := cookies{} 278 | cs["hydra"] = res.Cookies() 279 | 280 | // Navigate to auth request page 281 | res, err = client.Get(res.Header.Get("Location")) 282 | if err != nil { 283 | return "", nil, err 284 | } 285 | 286 | cs["auth"] = res.Cookies() 287 | return res.Request.URL.String(), cs, err 288 | } 289 | 290 | var errorRE = regexp.MustCompile(`
(?P.*)
`) 291 | 292 | // authenticate uses the user and pass to pass the authentication challenge and returns the authorization_code 293 | func (c *Config) authenticate(client HTTPClient, cookies cookies, uri, user, pass string) (string, error) { 294 | // Find csrf 295 | csrf := "" 296 | for _, cookie := range cookies["auth"] { 297 | if cookie.Name == "_csrf" && cookie.Value != "" { 298 | csrf = cookie.Value 299 | break 300 | } 301 | } 302 | query := url.Values{} 303 | query.Add("username", user) 304 | query.Add("password", pass) 305 | query.Add("csrf", csrf) 306 | query.Add("g-recaptcha-response", "") 307 | 308 | req, err := http.NewRequest("POST", uri, strings.NewReader(query.Encode())) 309 | if err != nil { 310 | return "", err 311 | } 312 | req.Header.Add("Content-Type", "application/x-www-form-urlencoded") 313 | 314 | // Apply cookies 315 | for _, cookie := range cookies["auth"] { 316 | req.AddCookie(cookie) 317 | } 318 | 319 | res, err := client.Do(req) 320 | if err != nil { 321 | return "", err 322 | } 323 | 324 | if res.StatusCode != 302 { 325 | body, errRead := ioutil.ReadAll(res.Body) 326 | if errRead != nil { 327 | return "", errRead 328 | } 329 | 330 | errs := errorRE.FindStringSubmatch(string(body)) 331 | if len(errs) < 2 { 332 | return "", errors.New("status = " + res.Status + ", response = " + string(body)) 333 | } 334 | return "", errors.New(errs[1]) 335 | } 336 | 337 | // Follow redirect to hydra 338 | req, err = http.NewRequest("GET", res.Header.Get("Location"), nil) 339 | if err != nil { 340 | return "", err 341 | } 342 | 343 | for _, cookie := range cookies["hydra"] { 344 | req.AddCookie(cookie) 345 | } 346 | 347 | res, err = client.Do(req) 348 | if err != nil { 349 | return "", err 350 | } 351 | 352 | redir, err := url.Parse(res.Header.Get("Location")) 353 | if err != nil { 354 | return "", err 355 | } 356 | 357 | return redir.Query().Get("code"), nil 358 | } 359 | 360 | func (c *Config) requestToken(client HTTPClient, code string) (*Token, error) { 361 | query := url.Values{} 362 | query.Add("code", code) 363 | query.Add("client_id", c.ClientID) 364 | query.Add("redirect_uri", c.RedirectURI) 365 | query.Add("grant_type", "authorization_code") 366 | 367 | req, err := http.NewRequest("POST", c.TokenURL, strings.NewReader(query.Encode())) 368 | if err != nil { 369 | return nil, err 370 | } 371 | 372 | req.Header.Add("Content-Type", "application/x-www-form-urlencoded") 373 | req.SetBasicAuth(c.ClientID, "") 374 | res, err := client.Do(req) 375 | if err != nil { 376 | return nil, err 377 | } 378 | 379 | body, err := ioutil.ReadAll(res.Body) 380 | if err != nil { 381 | return nil, err 382 | } 383 | 384 | if res.StatusCode != 200 { 385 | data := struct { 386 | Error string `json:"error_description"` 387 | }{} 388 | err = json.Unmarshal(body, &data) 389 | if err != nil { 390 | return nil, err 391 | } 392 | return nil, errors.New(data.Error) 393 | } 394 | 395 | data := Token{} 396 | 397 | err = json.Unmarshal(body, &data) 398 | if err != nil { 399 | return nil, err 400 | } 401 | return &data, nil 402 | } 403 | 404 | // randomString generates a string of random characters of fixed length. 405 | // stolen shamelessly from https://stackoverflow.com/questions/22892120/how-to-generate-a-random-string-of-a-fixed-length-in-golang 406 | const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" 407 | const ( 408 | letterIdxBits = 6 // 6 bits to represent a letter index 409 | letterIdxMask = 1<= 0; { 419 | if remain == 0 { 420 | cache, remain = src.Int63(), letterIdxMax 421 | } 422 | if idx := int(cache & letterIdxMask); idx < len(letterBytes) { 423 | b[i] = letterBytes[idx] 424 | i-- 425 | } 426 | cache >>= letterIdxBits 427 | remain-- 428 | } 429 | 430 | return string(b) 431 | } 432 | -------------------------------------------------------------------------------- /auth/auth_test.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "io/ioutil" 7 | "net/http" 8 | "net/textproto" 9 | "os" 10 | "strings" 11 | "testing" 12 | 13 | "github.com/stretchr/testify/assert" 14 | 15 | "github.com/pkg/errors" 16 | ) 17 | 18 | type MockClient struct{} 19 | 20 | var ( 21 | DoFunc func(req *http.Request) (*http.Response, error) 22 | GetFunc func(url string) (*http.Response, error) 23 | ) 24 | 25 | func (m *MockClient) Do(req *http.Request) (*http.Response, error) { 26 | return DoFunc(req) 27 | } 28 | 29 | func (m *MockClient) Get(url string) (resp *http.Response, err error) { 30 | return GetFunc(url) 31 | } 32 | 33 | func TestMain(m *testing.M) { 34 | client = &MockClient{} 35 | os.Exit(m.Run()) 36 | } 37 | 38 | func TestAuthStartError(t *testing.T) { 39 | DoFunc = func(req *http.Request) (*http.Response, error) { 40 | return nil, errors.New("Wanted error from mock web server") 41 | } 42 | 43 | data, err := StartDeviceAuth("", "0") 44 | 45 | assert.Error(t, err) 46 | assert.Equal(t, data, DeviceCode{}) 47 | } 48 | 49 | func TestAuthStartData(t *testing.T) { 50 | d := DeviceCode{ 51 | DeviceCode: "0", 52 | UserCode: "test", 53 | VerificationURI: "test", 54 | ExpiresIn: 1, 55 | Interval: 1, 56 | VerificationURIComplete: "test11", 57 | } 58 | DoFunc = func(req *http.Request) (*http.Response, error) { 59 | header := req.Header[textproto.CanonicalMIMEHeaderKey("content-type")] 60 | if len(header) != 1 { 61 | return nil, errors.New("content-type len is wrong") 62 | } 63 | 64 | if header[0] != "application/x-www-form-urlencoded" { 65 | return nil, errors.New("content-type is wrong") 66 | } 67 | 68 | if req.Method != http.MethodPost { 69 | return nil, errors.New("Method is wrong") 70 | } 71 | 72 | if !strings.Contains(req.URL.Path, "/oauth/device/code") { 73 | return nil, errors.New("url is wrong") 74 | } 75 | 76 | body, err := ioutil.ReadAll(req.Body) 77 | if err != nil { 78 | return nil, err 79 | } 80 | 81 | bodyStr := string(body) 82 | if !strings.Contains(bodyStr, "client_id=") || 83 | !strings.Contains(bodyStr, "&audience=https://api.arduino.cc") { 84 | return nil, errors.New("Payload is wrong") 85 | } 86 | 87 | var data bytes.Buffer 88 | if err := json.NewEncoder(&data).Encode(d); err != nil { 89 | return nil, err 90 | } 91 | 92 | return &http.Response{ 93 | Body: ioutil.NopCloser(&data), 94 | }, nil 95 | } 96 | 97 | data, err := StartDeviceAuth("", "0") 98 | 99 | assert.NoError(t, err) 100 | assert.Equal(t, data, d) 101 | } 102 | 103 | func TestAuthCheck(t *testing.T) { 104 | type AuthAccess struct { 105 | AccessToken string `json:"access_token"` 106 | ExpiresIn int `json:"expires_in"` 107 | TokenType string `json:"token_type"` 108 | } 109 | 110 | DoFunc = func(req *http.Request) (*http.Response, error) { 111 | header := req.Header[textproto.CanonicalMIMEHeaderKey("content-type")] 112 | if len(header) != 1 { 113 | return nil, errors.New("content-type len is wrong") 114 | } 115 | 116 | if header[0] != "application/x-www-form-urlencoded" { 117 | return nil, errors.New("content-type is wrong") 118 | } 119 | 120 | if req.Method != http.MethodPost { 121 | return nil, errors.New("Method is wrong") 122 | } 123 | 124 | if !strings.Contains(req.URL.Path, "/oauth/token") { 125 | return nil, errors.New("url is wrong") 126 | } 127 | 128 | body, err := ioutil.ReadAll(req.Body) 129 | if err != nil { 130 | return nil, err 131 | } 132 | 133 | bodyStr := string(body) 134 | if !strings.Contains(bodyStr, "client_id=") || 135 | !strings.Contains(bodyStr, "grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code&device_code=") { 136 | return nil, errors.New("Payload is wrong") 137 | } 138 | 139 | var data bytes.Buffer 140 | if err := json.NewEncoder(&data).Encode(AuthAccess{ 141 | AccessToken: "asdf", 142 | ExpiresIn: 999, 143 | TokenType: "testType", 144 | }); err != nil { 145 | return nil, err 146 | } 147 | 148 | return &http.Response{ 149 | StatusCode: 200, 150 | Body: ioutil.NopCloser(&data), 151 | }, nil 152 | } 153 | 154 | token, err := CheckDeviceAuth("", "0", "") 155 | 156 | assert.NoError(t, err) 157 | assert.Equal(t, "asdf", token) 158 | } 159 | 160 | func TestDefaultConfig(t *testing.T) { 161 | assert.Equal(t, New(), &Config{ 162 | CodeURL: "https://hydra.arduino.cc/oauth2/auth", 163 | TokenURL: "https://hydra.arduino.cc/oauth2/token", 164 | ClientID: "cli", 165 | RedirectURI: "http://localhost:5000", 166 | Scopes: "profile:core offline", 167 | }) 168 | } 169 | 170 | func TestRequestAuthError(t *testing.T) { 171 | config := Config{ 172 | CodeURL: "www.test.com", 173 | } 174 | 175 | GetFunc = func(url string) (*http.Response, error) { 176 | return nil, errors.New("Wanted error from mock web server") 177 | } 178 | 179 | _, _, err := config.requestAuth(client) 180 | assert.Error(t, err) 181 | } 182 | 183 | func TestRequestAuth(t *testing.T) { 184 | config := Config{ 185 | ClientID: "1", 186 | CodeURL: "www.test.com", 187 | RedirectURI: "http://localhost:5000", 188 | Scopes: "profile:core offline", 189 | } 190 | 191 | countGetCall := 0 192 | GetFunc = func(url string) (*http.Response, error) { 193 | countGetCall++ 194 | 195 | if countGetCall == 1 { 196 | if !strings.Contains(url, "www.test.com?client_id=1&redirect_uri=http%3A%2F%2Flocalhost%3A5000&response_type=code&scope=profile%3Acore+offline&state=") { 197 | return nil, errors.New("Error in url") 198 | } 199 | 200 | return &http.Response{ 201 | StatusCode: 200, 202 | }, nil 203 | } 204 | 205 | if url != "" { 206 | return nil, errors.New("url should be empty because no Location is provided in Header") 207 | } 208 | 209 | r, err := http.NewRequest("GET", "www.test.com", nil) 210 | if err != nil { 211 | return nil, err 212 | } 213 | 214 | return &http.Response{ 215 | StatusCode: 200, 216 | Request: r, 217 | }, nil 218 | } 219 | 220 | res, biscuits, err := config.requestAuth(client) 221 | 222 | assert.NoError(t, err) 223 | assert.Equal(t, biscuits, cookies{ 224 | "hydra": []*http.Cookie{}, 225 | "auth": []*http.Cookie{}, 226 | }) 227 | assert.Equal(t, "www.test.com", res) 228 | } 229 | 230 | func TestAuthenticateError(t *testing.T) { 231 | config := Config{ 232 | ClientID: "1", 233 | CodeURL: "www.test.com", 234 | RedirectURI: "http://localhost:5000", 235 | Scopes: "profile:core offline", 236 | } 237 | 238 | DoFunc = func(req *http.Request) (*http.Response, error) { 239 | header := req.Header[textproto.CanonicalMIMEHeaderKey("content-type")] 240 | if len(header) != 1 { 241 | return nil, errors.New("content-type len is wrong") 242 | } 243 | 244 | if header[0] != "application/x-www-form-urlencoded" { 245 | return nil, errors.New("content-type is wrong") 246 | } 247 | 248 | if req.Method != http.MethodPost { 249 | return nil, errors.New("Method is wrong") 250 | } 251 | 252 | if !strings.Contains(req.URL.Path, "www.test.io") { 253 | return nil, errors.New("url is wrong") 254 | } 255 | 256 | body, err := ioutil.ReadAll(req.Body) 257 | if err != nil { 258 | return nil, err 259 | } 260 | 261 | bodyStr := string(body) 262 | if !strings.Contains(bodyStr, "username=") || 263 | !strings.Contains(bodyStr, "password") || 264 | !strings.Contains(bodyStr, "csrf") || 265 | !strings.Contains(bodyStr, "g-recaptcha-response") { 266 | return nil, errors.New("Payload is wrong") 267 | } 268 | 269 | return &http.Response{ 270 | StatusCode: 200, 271 | Status: "200 OK", 272 | Body: ioutil.NopCloser(strings.NewReader("testBody")), 273 | }, nil 274 | } 275 | 276 | response, err := config.authenticate(client, cookies{}, "www.test.io", "user", "pw") 277 | 278 | assert.Error(t, err) 279 | assert.Equal(t, "", response) 280 | } 281 | 282 | func TestAuthenticate(t *testing.T) { 283 | config := Config{ 284 | ClientID: "1", 285 | CodeURL: "www.test.com", 286 | RedirectURI: "http://localhost:5000", 287 | Scopes: "profile:core offline", 288 | } 289 | 290 | countDo := 0 291 | DoFunc = func(req *http.Request) (*http.Response, error) { 292 | countDo++ 293 | if countDo == 1 { 294 | header := req.Header[textproto.CanonicalMIMEHeaderKey("content-type")] 295 | if len(header) != 1 { 296 | return nil, errors.New("content-type len is wrong") 297 | } 298 | 299 | if header[0] != "application/x-www-form-urlencoded" { 300 | return nil, errors.New("content-type is wrong") 301 | } 302 | 303 | if req.Method != http.MethodPost { 304 | return nil, errors.New("Method is wrong") 305 | } 306 | 307 | if !strings.Contains(req.URL.Path, "www.test.io") { 308 | return nil, errors.New("url is wrong") 309 | } 310 | 311 | body, err := ioutil.ReadAll(req.Body) 312 | if err != nil { 313 | return nil, err 314 | } 315 | 316 | bodyStr := string(body) 317 | if !strings.Contains(bodyStr, "username=") || 318 | !strings.Contains(bodyStr, "password") || 319 | !strings.Contains(bodyStr, "csrf") || 320 | !strings.Contains(bodyStr, "g-recaptcha-response") { 321 | return nil, errors.New("Payload is wrong") 322 | } 323 | 324 | resp := &http.Response{ 325 | StatusCode: 302, 326 | Status: "302 OK", 327 | Header: http.Header{}, 328 | Body: ioutil.NopCloser(strings.NewReader("testBody")), 329 | } 330 | resp.Header.Set("Location", "www.redirect.io") 331 | 332 | return resp, nil 333 | } 334 | 335 | if req.Method != http.MethodGet { 336 | return nil, errors.New("Method is wrong") 337 | } 338 | 339 | if !strings.Contains(req.URL.Path, "www.redirect.io") { 340 | return nil, errors.New("redirect url is wrong") 341 | } 342 | 343 | return &http.Response{ 344 | StatusCode: 200, 345 | Status: "200 OK", 346 | Body: ioutil.NopCloser(strings.NewReader("testBody")), 347 | }, nil 348 | } 349 | 350 | response, err := config.authenticate(client, cookies{}, "www.test.io", "user", "pw") 351 | 352 | assert.NoError(t, err) 353 | assert.Equal(t, "", response) 354 | } 355 | 356 | func TestRequestTokenError(t *testing.T) { 357 | c := Config{} 358 | 359 | DoFunc = func(req *http.Request) (*http.Response, error) { 360 | return nil, errors.New("Wanted error from mock web server") 361 | } 362 | 363 | token, err := c.requestToken(client, "9") 364 | 365 | assert.Error(t, err) 366 | assert.True(t, token == nil) 367 | } 368 | 369 | func TestRequestToken(t *testing.T) { 370 | c := Config{} 371 | 372 | expectedToken := Token{ 373 | Access: "1234", 374 | Refresh: "", 375 | TTL: 99, 376 | Scopes: "", 377 | Type: "Bearer", 378 | } 379 | 380 | DoFunc = func(req *http.Request) (*http.Response, error) { 381 | var data bytes.Buffer 382 | if err := json.NewEncoder(&data).Encode(expectedToken); err != nil { 383 | return nil, err 384 | } 385 | 386 | return &http.Response{ 387 | StatusCode: 200, 388 | Status: "200 OK", 389 | Body: ioutil.NopCloser(&data), 390 | }, nil 391 | } 392 | 393 | token, err := c.requestToken(client, "9") 394 | 395 | assert.NoError(t, err) 396 | assert.Equal(t, expectedToken, *token) 397 | } 398 | -------------------------------------------------------------------------------- /extra/main.go: -------------------------------------------------------------------------------- 1 | // 2 | // This file is part of arduino-connector 3 | // 4 | // Copyright (C) 2017-2018 Arduino AG (http://www.arduino.cc/) 5 | // 6 | // Licensed under the Apache License, Version 2.0 (the "License"); 7 | // you may not use this file except in compliance with the License. 8 | // You may obtain a copy of the License at 9 | // 10 | // http://www.apache.org/licenses/LICENSE-2.0 11 | // 12 | // Unless required by applicable law or agreed to in writing, software 13 | // distributed under the License is distributed on an "AS IS" BASIS, 14 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | // See the License for the specific language governing permissions and 16 | // limitations under the License. 17 | // 18 | 19 | package main 20 | 21 | import ( 22 | "encoding/json" 23 | "fmt" 24 | "os" 25 | "path/filepath" 26 | "strings" 27 | ) 28 | 29 | type DylibMap struct { 30 | Name string 31 | Provides []string 32 | URL string 33 | Help string 34 | } 35 | 36 | func main() { 37 | var v []DylibMap 38 | 39 | for _, arg := range os.Args[1:] { 40 | var lib DylibMap 41 | lib.Name = filepath.Base(arg) 42 | 43 | err := filepath.Walk(arg, func(path string, f os.FileInfo, err error) error { 44 | if strings.Contains(f.Name(), ".so") { 45 | lib.Provides = append(lib.Provides, f.Name()) 46 | } 47 | return nil 48 | }) 49 | if err != nil { 50 | panic(err) 51 | } 52 | lib.Help = "Please install " + lib.Name + " library from Intel website http://intel.com/" 53 | v = append(v, lib) 54 | } 55 | bytes, err := json.Marshal(v) 56 | if err == nil { 57 | fmt.Println(string(bytes)) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/arduino/arduino-connector 2 | 3 | go 1.14 4 | 5 | require ( 6 | github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78 // indirect 7 | github.com/Microsoft/go-winio v0.4.10 // indirect 8 | github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 // indirect 9 | github.com/StackExchange/wmi v0.0.0-20180116203802-5d049714c4a6 // indirect 10 | github.com/arduino/go-apt-client v0.0.0-20180125162211-1e18c69bac9f 11 | github.com/arduino/go-system-stats v0.0.0-20180215112344-1598cba505aa 12 | github.com/arduino/gonetworkmanager v0.0.0-20180822160505-c0a58e05a154 // indirect 13 | github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973 // indirect 14 | github.com/blang/semver v3.5.1+incompatible 15 | github.com/containerd/continuity v0.0.0-20181003075958-be9bd761db19 // indirect 16 | github.com/docker/cli v0.0.0-20180905184309-44371c7c34d5 17 | github.com/docker/distribution v2.6.0-rc.1.0.20180327202408-83389a148052+incompatible // indirect 18 | github.com/docker/docker v17.12.0-ce-rc1.0.20180822115147-a0385f7ad7f8+incompatible 19 | github.com/docker/docker-credential-helpers v0.6.1 // indirect 20 | github.com/docker/go-connections v0.4.0 // indirect 21 | github.com/docker/go-metrics v0.0.0-20180209012529-399ea8c73916 // indirect 22 | github.com/docker/go-units v0.3.3 // indirect 23 | github.com/docker/libtrust v0.0.0-20160708172513-aabc10ec26b7 // indirect 24 | github.com/eclipse/paho.mqtt.golang v1.2.0 25 | github.com/facchinm/service v0.0.0-20180209083557-5cffdea7926c 26 | github.com/fsnotify/fsnotify v1.4.7 27 | github.com/go-ole/go-ole v1.2.1 // indirect 28 | github.com/godbus/dbus v4.1.0+incompatible // indirect 29 | github.com/gogo/protobuf v1.1.1 // indirect 30 | github.com/google/go-cmp v0.4.0 // indirect 31 | github.com/gorilla/context v1.1.1 // indirect 32 | github.com/gorilla/mux v1.6.2 // indirect 33 | github.com/hpcloud/tail v1.0.1-0.20180514194441-a1dbeea552b7 34 | github.com/kardianos/osext v0.0.0-20170510131534-ae77be60afb1 35 | github.com/kr/binarydist v0.1.0 36 | github.com/kr/pty v1.1.2 37 | github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect 38 | github.com/namsral/flag v1.7.4-pre 39 | github.com/nats-io/gnatsd v1.2.0 40 | github.com/nats-io/go-nats v1.5.0 41 | github.com/nats-io/nuid v1.0.0 // indirect 42 | github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect 43 | github.com/opencontainers/go-digest v1.0.0-rc1 // indirect 44 | github.com/opencontainers/image-spec v1.0.1 // indirect 45 | github.com/opencontainers/runc v0.1.1 // indirect 46 | github.com/pkg/errors v0.8.0 47 | github.com/prometheus/client_golang v0.9.0-pre1 // indirect 48 | github.com/prometheus/common v0.0.0-20180801064454-c7de2306084e // indirect 49 | github.com/prometheus/procfs v0.0.0-20181004131639-6bfc2c70c4ee // indirect 50 | github.com/satori/go.uuid v1.2.0 // indirect 51 | github.com/shirou/gopsutil v2.17.13-0.20180801053943-8048a2e9c577+incompatible 52 | github.com/shirou/w32 v0.0.0-20160930032740-bb4de0191aa4 // indirect 53 | github.com/sirupsen/logrus v1.1.0 // indirect 54 | github.com/stretchr/testify v1.3.0 55 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 56 | golang.org/x/net v0.0.0-20200707034311-ab3426394381 57 | golang.org/x/time v0.0.0-20200416051211-89c76fbcd5d1 // indirect 58 | google.golang.org/grpc v1.29.1 // indirect 59 | gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f // indirect 60 | gopkg.in/fsnotify/fsnotify.v1 v1.4.7 // indirect 61 | gopkg.in/inconshreveable/go-update.v0 v0.0.0-20150814200126-d8b0b1d421aa 62 | gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect 63 | gotest.tools v2.2.0+incompatible // indirect 64 | ) 65 | -------------------------------------------------------------------------------- /handlers_apt_packages.go: -------------------------------------------------------------------------------- 1 | // 2 | // This file is part of arduino-connector 3 | // 4 | // Copyright (C) 2017-2018 Arduino AG (http://www.arduino.cc/) 5 | // 6 | // Licensed under the Apache License, Version 2.0 (the "License"); 7 | // you may not use this file except in compliance with the License. 8 | // You may obtain a copy of the License at 9 | // 10 | // http://www.apache.org/licenses/LICENSE-2.0 11 | // 12 | // Unless required by applicable law or agreed to in writing, software 13 | // distributed under the License is distributed on an "AS IS" BASIS, 14 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | // See the License for the specific language governing permissions and 16 | // limitations under the License. 17 | // 18 | 19 | package main 20 | 21 | import ( 22 | "encoding/json" 23 | "fmt" 24 | 25 | apt "github.com/arduino/go-apt-client" 26 | mqtt "github.com/eclipse/paho.mqtt.golang" 27 | ) 28 | 29 | // AptGetEvent sends the status for a specific package 30 | func (s *Status) AptGetEvent(client mqtt.Client, msg mqtt.Message) { 31 | var params struct { 32 | Package string `json:"package"` 33 | } 34 | err := json.Unmarshal(msg.Payload(), ¶ms) 35 | if err != nil { 36 | s.Error("/apt/get", fmt.Errorf("Unmarshal '%s': %s", msg.Payload(), err)) 37 | return 38 | } 39 | 40 | // Get package from system 41 | var res []*apt.Package 42 | res, err = apt.Search(params.Package) 43 | 44 | if err != nil { 45 | s.Error("/apt/get", fmt.Errorf("Retrieving package data: %s", err)) 46 | return 47 | } 48 | 49 | //If package is upgradable set the status to "upgradable" 50 | allUpdates, err := apt.ListUpgradable() 51 | if err != nil { 52 | s.Error("/apt/get", fmt.Errorf("Retrieving package: %s", err)) 53 | return 54 | } 55 | 56 | for _, update := range allUpdates { 57 | for i := range res { 58 | if update.Name == res[i].Name { 59 | res[i].Status = "upgradable" 60 | break 61 | } 62 | } 63 | } 64 | 65 | // Prepare response payload 66 | type response struct { 67 | Packages []*apt.Package `json:"packages"` 68 | } 69 | info := response{ 70 | Packages: res, 71 | } 72 | 73 | // Send result 74 | data, err := json.Marshal(info) 75 | if err != nil { 76 | s.Error("/apt/get", fmt.Errorf("Json marshal result: %s", err)) 77 | return 78 | } 79 | 80 | //var out bytes.Buffer 81 | //json.Indent(&out, data, "", " ") 82 | //fmt.Println(string(out.Bytes())) 83 | 84 | s.Info("/apt/get", string(data)+"\n") 85 | } 86 | 87 | // AptListEvent sends a list of available packages and their status 88 | func (s *Status) AptListEvent(client mqtt.Client, msg mqtt.Message) { 89 | const itemsPerPage = 30 90 | 91 | var params struct { 92 | Search string `json:"search"` 93 | Page int `json:"page"` 94 | } 95 | err := json.Unmarshal(msg.Payload(), ¶ms) 96 | if err != nil { 97 | s.Error("/apt/list", fmt.Errorf("Unmarshal '%s': %s", msg.Payload(), err)) 98 | return 99 | } 100 | 101 | // Get packages from system 102 | var all []*apt.Package 103 | if params.Search == "" { 104 | all, err = apt.ListUpgradable() 105 | } else { 106 | all, err = apt.Search("*" + params.Search + "*") 107 | } 108 | 109 | if err != nil { 110 | s.Error("/apt/list", fmt.Errorf("Retrieving packages: %s", err)) 111 | return 112 | } 113 | 114 | // Paginate data 115 | total := len(all) 116 | pages := (total-1)/itemsPerPage + 1 117 | first := params.Page * itemsPerPage 118 | last := first + itemsPerPage 119 | if first >= total { 120 | all = all[0:0] 121 | } else if last >= total { 122 | all = all[first:] 123 | } else { 124 | all = all[first:last] 125 | } 126 | 127 | // On upgradable packages set the status to "upgradable" 128 | allUpdates, err := apt.ListUpgradable() 129 | if err != nil { 130 | s.Error("/apt/list", fmt.Errorf("Retrieving packages: %s", err)) 131 | return 132 | } 133 | 134 | for _, update := range allUpdates { 135 | for i := range all { 136 | if update.Name == all[i].Name { 137 | all[i].Status = "upgradable" 138 | break 139 | } 140 | } 141 | } 142 | 143 | // Prepare response payload 144 | type response struct { 145 | Packages []*apt.Package `json:"packages"` 146 | Page int `json:"page"` 147 | Pages int `json:"pages"` 148 | TotalItems int `json:"total_items"` 149 | } 150 | info := response{ 151 | Packages: all, 152 | Page: params.Page, 153 | Pages: pages, 154 | TotalItems: total, 155 | } 156 | 157 | // Send result 158 | data, err := json.Marshal(info) 159 | if err != nil { 160 | s.Error("/apt/list", fmt.Errorf("Json marshal result: %s", err)) 161 | return 162 | } 163 | 164 | //var out bytes.Buffer 165 | //json.Indent(&out, data, "", " ") 166 | //fmt.Println(string(out.Bytes())) 167 | 168 | s.Info("/apt/list", string(data)+"\n") 169 | } 170 | 171 | // AptInstallEvent installs new packages 172 | func (s *Status) AptInstallEvent(client mqtt.Client, msg mqtt.Message) { 173 | var params struct { 174 | Packages []string `json:"packages"` 175 | } 176 | err := json.Unmarshal(msg.Payload(), ¶ms) 177 | if err != nil { 178 | s.Error("/apt/install", fmt.Errorf("Unmarshal '%s': %s", msg.Payload(), err)) 179 | return 180 | } 181 | 182 | toInstall := []*apt.Package{} 183 | for _, p := range params.Packages { 184 | toInstall = append(toInstall, &apt.Package{Name: p}) 185 | } 186 | out, err := apt.Install(toInstall...) 187 | if err != nil { 188 | s.Error("/apt/install", fmt.Errorf("Running installer: %s\nOutput:\n%s", err, out)) 189 | return 190 | } 191 | s.InfoCommandOutput("/apt/install", out) 192 | } 193 | 194 | // AptUpdateEvent checks repositories for updates on installed packages 195 | func (s *Status) AptUpdateEvent(client mqtt.Client, msg mqtt.Message) { 196 | out, err := apt.CheckForUpdates() 197 | if err != nil { 198 | s.Error("/apt/update", fmt.Errorf("Checking for updates: %s\nOutput:\n%s", err, out)) 199 | return 200 | } 201 | s.InfoCommandOutput("/apt/update", out) 202 | } 203 | 204 | // AptUpgradeEvent installs upgrade for specified packages (or for all 205 | // upgradable packages if none are specified) 206 | func (s *Status) AptUpgradeEvent(client mqtt.Client, msg mqtt.Message) { 207 | var params struct { 208 | Packages []string `json:"packages"` 209 | } 210 | err := json.Unmarshal(msg.Payload(), ¶ms) 211 | if err != nil { 212 | s.Error("/apt/upgrade", fmt.Errorf("Unmarshal '%s': %s", msg.Payload(), err)) 213 | return 214 | } 215 | 216 | toUpgrade := []*apt.Package{} 217 | for _, p := range params.Packages { 218 | toUpgrade = append(toUpgrade, &apt.Package{Name: p}) 219 | } 220 | 221 | if len(toUpgrade) == 0 { 222 | out, err := apt.UpgradeAll() 223 | if err != nil { 224 | s.Error("/apt/upgrade", fmt.Errorf("Upgrading all packages: %s\nOutput:\n%s", err, out)) 225 | return 226 | } 227 | s.InfoCommandOutput("/apt/upgrade", out) 228 | } else { 229 | out, err := apt.Upgrade(toUpgrade...) 230 | if err != nil { 231 | s.Error("/apt/upgrade", fmt.Errorf("Upgrading %+v: %s\nOutput:\n%s", params, err, out)) 232 | return 233 | } 234 | s.InfoCommandOutput("/apt/upgrade", out) 235 | } 236 | } 237 | 238 | // AptRemoveEvent deinstall the specified packages 239 | func (s *Status) AptRemoveEvent(client mqtt.Client, msg mqtt.Message) { 240 | var params struct { 241 | Packages []string `json:"packages"` 242 | } 243 | err := json.Unmarshal(msg.Payload(), ¶ms) 244 | if err != nil { 245 | s.Error("/apt/remove", fmt.Errorf("Unmarshal '%s': %s", msg.Payload(), err)) 246 | return 247 | } 248 | 249 | toRemove := []*apt.Package{} 250 | for _, p := range params.Packages { 251 | toRemove = append(toRemove, &apt.Package{Name: p}) 252 | } 253 | 254 | out, err := apt.Remove(toRemove...) 255 | if err != nil { 256 | s.Error("/apt/remove", fmt.Errorf("Removing %+v: %s\nOutput:\n%s", params, err, out)) 257 | return 258 | } 259 | s.InfoCommandOutput("/apt/remove", out) 260 | } 261 | -------------------------------------------------------------------------------- /handlers_apt_repositories.go: -------------------------------------------------------------------------------- 1 | // 2 | // This file is part of arduino-connector 3 | // 4 | // Copyright (C) 2017-2020 Arduino AG (http://www.arduino.cc/) 5 | // 6 | // Licensed under the Apache License, Version 2.0 (the "License"); 7 | // you may not use this file except in compliance with the License. 8 | // You may obtain a copy of the License at 9 | // 10 | // http://www.apache.org/licenses/LICENSE-2.0 11 | // 12 | // Unless required by applicable law or agreed to in writing, software 13 | // distributed under the License is distributed on an "AS IS" BASIS, 14 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | // See the License for the specific language governing permissions and 16 | // limitations under the License. 17 | // 18 | 19 | package main 20 | 21 | import ( 22 | "encoding/json" 23 | "fmt" 24 | 25 | apt "github.com/arduino/go-apt-client" 26 | mqtt "github.com/eclipse/paho.mqtt.golang" 27 | ) 28 | 29 | // AptRepositoryListEvent sends a list of available repositories 30 | func (s *Status) AptRepositoryListEvent(client mqtt.Client, msg mqtt.Message) { 31 | all, err := apt.ParseAPTConfigFolder("/etc/apt") 32 | if err != nil { 33 | s.Error("/apt/repos/list", fmt.Errorf("Retrieving repositories: %s", err)) 34 | return 35 | } 36 | 37 | data, err := json.Marshal(all) 38 | if err != nil { 39 | s.Error("/apt/repos/list", fmt.Errorf("Json marshal result: %s", err)) 40 | return 41 | } 42 | 43 | s.SendInfo(s.topicPertinence+"/apt/repos/list", string(data)) 44 | } 45 | 46 | // AptRepositoryAddEvent adds a repository to the apt configuration 47 | func (s *Status) AptRepositoryAddEvent(client mqtt.Client, msg mqtt.Message) { 48 | var params struct { 49 | Repository *apt.Repository `json:"repository"` 50 | } 51 | err := json.Unmarshal(msg.Payload(), ¶ms) 52 | if err != nil { 53 | s.Error("/apt/repos/add", fmt.Errorf("Unmarshal '%s': %s", msg.Payload(), err)) 54 | return 55 | } 56 | 57 | err = apt.AddRepository(params.Repository, "/etc/apt") 58 | if err != nil { 59 | s.Error("/apt/repos/add", fmt.Errorf("Adding repository '%s': %s", msg.Payload(), err)) 60 | return 61 | } 62 | 63 | s.SendInfo(s.topicPertinence+"/apt/repos/add", "OK") 64 | } 65 | 66 | // AptRepositoryRemoveEvent removes a repository from the apt configuration 67 | func (s *Status) AptRepositoryRemoveEvent(client mqtt.Client, msg mqtt.Message) { 68 | var params struct { 69 | Repository *apt.Repository `json:"repository"` 70 | } 71 | err := json.Unmarshal(msg.Payload(), ¶ms) 72 | if err != nil { 73 | s.Error("/apt/repos/remove", fmt.Errorf("Unmarshal '%s': %s", msg.Payload(), err)) 74 | return 75 | } 76 | 77 | err = apt.RemoveRepository(params.Repository, "/etc/apt") 78 | if err != nil { 79 | s.Error("/apt/repos/remove", fmt.Errorf("Removing repository '%s': %s", msg.Payload(), err)) 80 | return 81 | } 82 | 83 | s.SendInfo(s.topicPertinence+"/apt/repos/remove", "OK") 84 | } 85 | 86 | // AptRepositoryEditEvent modifies a repository definition in the apt configuration 87 | func (s *Status) AptRepositoryEditEvent(client mqtt.Client, msg mqtt.Message) { 88 | var params struct { 89 | OldRepository *apt.Repository `json:"old_repository"` 90 | NewRepository *apt.Repository `json:"new_repository"` 91 | } 92 | err := json.Unmarshal(msg.Payload(), ¶ms) 93 | if err != nil { 94 | s.Error("/apt/repos/edit", fmt.Errorf("Unmarshal '%s': %s", msg.Payload(), err)) 95 | return 96 | } 97 | 98 | err = apt.EditRepository(params.OldRepository, params.NewRepository, "/etc/apt") 99 | if err != nil { 100 | s.Error("/apt/repos/edit", fmt.Errorf("Changing repository '%s': %s", msg.Payload(), err)) 101 | return 102 | } 103 | 104 | s.SendInfo(s.topicPertinence+"/apt/repos/edit", "OK") 105 | } 106 | -------------------------------------------------------------------------------- /handlers_apt_repositories_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "log" 7 | "strings" 8 | "testing" 9 | "time" 10 | 11 | apt "github.com/arduino/go-apt-client" 12 | mqtt "github.com/eclipse/paho.mqtt.golang" 13 | "github.com/stretchr/testify/assert" 14 | ) 15 | 16 | func TestAptList(t *testing.T) { 17 | ui := NewMqttTestClientLocal() 18 | defer ui.Close() 19 | 20 | s := NewStatus(program{}.Config, nil, nil, "") 21 | s.mqttClient = mqtt.NewClient(mqtt.NewClientOptions().AddBroker("tcp://localhost:1883").SetClientID("arduino-connector")) 22 | if token := s.mqttClient.Connect(); token.Wait() && token.Error() != nil { 23 | log.Fatal(token.Error()) 24 | } 25 | defer s.mqttClient.Disconnect(100) 26 | 27 | subscribeTopic(s.mqttClient, "0", "/apt/repos/list/post", s, s.AptRepositoryListEvent, false) 28 | 29 | resp := ui.MqttSendAndReceiveTimeout(t, "/apt/repos/list", "{}", 1*time.Second) 30 | 31 | assert.NotEmpty(t, resp) 32 | } 33 | 34 | func TestAptAddError(t *testing.T) { 35 | ui := NewMqttTestClientLocal() 36 | defer ui.Close() 37 | 38 | s := NewStatus(program{}.Config, nil, nil, "") 39 | s.mqttClient = mqtt.NewClient(mqtt.NewClientOptions().AddBroker("tcp://localhost:1883").SetClientID("arduino-connector")) 40 | if token := s.mqttClient.Connect(); token.Wait() && token.Error() != nil { 41 | log.Fatal(token.Error()) 42 | } 43 | defer s.mqttClient.Disconnect(100) 44 | 45 | subscribeTopic(s.mqttClient, "0", "/apt/repos/add/post", s, s.AptRepositoryAddEvent, true) 46 | 47 | resp := ui.MqttSendAndReceiveTimeout(t, "/apt/repos/add", "{test}", 1*time.Second) 48 | 49 | assert.True(t, strings.HasPrefix(resp, "ERROR")) 50 | assert.True(t, strings.Contains(resp, "Unmarshal")) 51 | } 52 | 53 | func TestAptAdd(t *testing.T) { 54 | ui := NewMqttTestClientLocal() 55 | defer ui.Close() 56 | 57 | s := NewStatus(program{}.Config, nil, nil, "") 58 | s.mqttClient = mqtt.NewClient(mqtt.NewClientOptions().AddBroker("tcp://localhost:1883").SetClientID("arduino-connector")) 59 | if token := s.mqttClient.Connect(); token.Wait() && token.Error() != nil { 60 | log.Fatal(token.Error()) 61 | } 62 | defer s.mqttClient.Disconnect(100) 63 | 64 | subscribeTopic(s.mqttClient, "0", "/apt/repos/add/post", s, s.AptRepositoryAddEvent, true) 65 | 66 | var params struct { 67 | Repository *apt.Repository `json:"repository"` 68 | } 69 | 70 | params.Repository = &apt.Repository{ 71 | Enabled: false, 72 | SourceRepo: true, 73 | URI: "http://ppa.launchpad.net/test/ubuntu", 74 | Distribution: "zesty", 75 | Components: "main", 76 | Comment: "", 77 | } 78 | 79 | data, err := json.Marshal(params) 80 | if err != nil { 81 | t.Error(err) 82 | } 83 | 84 | resp := ui.MqttSendAndReceiveTimeout(t, "/apt/repos/add", string(data), 1*time.Second) 85 | assert.Equal(t, "INFO: OK\n", resp) 86 | 87 | defer func() { 88 | err = apt.RemoveRepository(params.Repository, "/etc/apt") 89 | if err != nil { 90 | t.Error(err) 91 | } 92 | }() 93 | 94 | all, err := apt.ParseAPTConfigFolder("/etc/apt") 95 | if err != nil { 96 | s.Error("/apt/repos/list", fmt.Errorf("Retrieving repositories: %s", err)) 97 | return 98 | } 99 | 100 | assert.True(t, all.Contains(params.Repository)) 101 | } 102 | 103 | func TestAptRemoveError(t *testing.T) { 104 | ui := NewMqttTestClientLocal() 105 | defer ui.Close() 106 | 107 | s := NewStatus(program{}.Config, nil, nil, "") 108 | s.mqttClient = mqtt.NewClient(mqtt.NewClientOptions().AddBroker("tcp://localhost:1883").SetClientID("arduino-connector")) 109 | if token := s.mqttClient.Connect(); token.Wait() && token.Error() != nil { 110 | log.Fatal(token.Error()) 111 | } 112 | defer s.mqttClient.Disconnect(100) 113 | 114 | subscribeTopic(s.mqttClient, "0", "/apt/repos/remove/post", s, s.AptRepositoryRemoveEvent, true) 115 | 116 | resp := ui.MqttSendAndReceiveTimeout(t, "/apt/repos/remove", "{test}", 1*time.Second) 117 | 118 | assert.True(t, strings.HasPrefix(resp, "ERROR")) 119 | assert.True(t, strings.Contains(resp, "Unmarshal")) 120 | } 121 | 122 | func TestAptRemove(t *testing.T) { 123 | ui := NewMqttTestClientLocal() 124 | defer ui.Close() 125 | 126 | s := NewStatus(program{}.Config, nil, nil, "") 127 | s.mqttClient = mqtt.NewClient(mqtt.NewClientOptions().AddBroker("tcp://localhost:1883").SetClientID("arduino-connector")) 128 | if token := s.mqttClient.Connect(); token.Wait() && token.Error() != nil { 129 | log.Fatal(token.Error()) 130 | } 131 | defer s.mqttClient.Disconnect(100) 132 | 133 | subscribeTopic(s.mqttClient, "0", "/apt/repos/remove/post", s, s.AptRepositoryRemoveEvent, true) 134 | 135 | var params struct { 136 | Repository *apt.Repository `json:"repository"` 137 | } 138 | 139 | params.Repository = &apt.Repository{ 140 | Enabled: false, 141 | SourceRepo: true, 142 | URI: "http://ppa.launchpad.net/test/ubuntu", 143 | Distribution: "zesty", 144 | Components: "main", 145 | Comment: "", 146 | } 147 | 148 | errAdd := apt.AddRepository(params.Repository, "/etc/apt") 149 | if errAdd != nil { 150 | t.Error(errAdd) 151 | } 152 | 153 | data, err := json.Marshal(params) 154 | if err != nil { 155 | t.Error(err) 156 | } 157 | 158 | resp := ui.MqttSendAndReceiveTimeout(t, "/apt/repos/remove", string(data), 1*time.Second) 159 | assert.Equal(t, "INFO: OK\n", resp) 160 | 161 | all, err := apt.ParseAPTConfigFolder("/etc/apt") 162 | if err != nil { 163 | s.Error("/apt/repos/list", fmt.Errorf("Retrieving repositories: %s", err)) 164 | return 165 | } 166 | 167 | assert.False(t, all.Contains(params.Repository)) 168 | } 169 | 170 | func TestAptEditError(t *testing.T) { 171 | ui := NewMqttTestClientLocal() 172 | defer ui.Close() 173 | 174 | s := NewStatus(program{}.Config, nil, nil, "") 175 | s.mqttClient = mqtt.NewClient(mqtt.NewClientOptions().AddBroker("tcp://localhost:1883").SetClientID("arduino-connector")) 176 | if token := s.mqttClient.Connect(); token.Wait() && token.Error() != nil { 177 | log.Fatal(token.Error()) 178 | } 179 | defer s.mqttClient.Disconnect(100) 180 | 181 | subscribeTopic(s.mqttClient, "0", "/apt/repos/edit/post", s, s.AptRepositoryEditEvent, true) 182 | 183 | resp := ui.MqttSendAndReceiveTimeout(t, "/apt/repos/edit", "{test}", 1*time.Second) 184 | 185 | assert.True(t, strings.HasPrefix(resp, "ERROR")) 186 | assert.True(t, strings.Contains(resp, "Unmarshal")) 187 | } 188 | 189 | func TestAptEdit(t *testing.T) { 190 | ui := NewMqttTestClientLocal() 191 | defer ui.Close() 192 | 193 | s := NewStatus(program{}.Config, nil, nil, "") 194 | s.mqttClient = mqtt.NewClient(mqtt.NewClientOptions().AddBroker("tcp://localhost:1883").SetClientID("arduino-connector")) 195 | if token := s.mqttClient.Connect(); token.Wait() && token.Error() != nil { 196 | log.Fatal(token.Error()) 197 | } 198 | defer s.mqttClient.Disconnect(100) 199 | 200 | subscribeTopic(s.mqttClient, "0", "/apt/repos/edit/post", s, s.AptRepositoryEditEvent, true) 201 | 202 | var params struct { 203 | OldRepository *apt.Repository `json:"old_repository"` 204 | NewRepository *apt.Repository `json:"new_repository"` 205 | } 206 | 207 | params.OldRepository = &apt.Repository{ 208 | Enabled: false, 209 | SourceRepo: true, 210 | URI: "http://ppa.launchpad.net/OldTest/ubuntu", 211 | Distribution: "zesty", 212 | Components: "main", 213 | Comment: "old", 214 | } 215 | 216 | params.NewRepository = &apt.Repository{ 217 | Enabled: false, 218 | SourceRepo: true, 219 | URI: "http://ppa.launchpad.net/NewTest/ubuntu", 220 | Distribution: "zesty", 221 | Components: "main", 222 | Comment: "new", 223 | } 224 | 225 | errAdd := apt.AddRepository(params.OldRepository, "/etc/apt") 226 | if errAdd != nil { 227 | t.Error(errAdd) 228 | } 229 | 230 | defer func() { 231 | err := apt.RemoveRepository(params.NewRepository, "/etc/apt") 232 | if err != nil { 233 | t.Error(err) 234 | } 235 | }() 236 | 237 | data, err := json.Marshal(params) 238 | if err != nil { 239 | t.Error(err) 240 | } 241 | 242 | resp := ui.MqttSendAndReceiveTimeout(t, "/apt/repos/edit", string(data), 1*time.Second) 243 | assert.Equal(t, "INFO: OK\n", resp) 244 | 245 | all, err := apt.ParseAPTConfigFolder("/etc/apt") 246 | if err != nil { 247 | s.Error("/apt/repos/list", fmt.Errorf("Retrieving repositories: %s", err)) 248 | return 249 | } 250 | 251 | assert.True(t, all.Contains(params.NewRepository)) 252 | assert.False(t, all.Contains(params.OldRepository)) 253 | } 254 | -------------------------------------------------------------------------------- /handlers_containers.go: -------------------------------------------------------------------------------- 1 | // 2 | // This file is part of arduino-connector 3 | // 4 | // Copyright (C) 2017-2020 Arduino AG (http://www.arduino.cc/) 5 | // 6 | // Licensed under the Apache License, Version 2.0 (the "License"); 7 | // you may not use this file except in compliance with the License. 8 | // You may obtain a copy of the License at 9 | // 10 | // http://www.apache.org/licenses/LICENSE-2.0 11 | // 12 | // Unless required by applicable law or agreed to in writing, software 13 | // distributed under the License is distributed on an "AS IS" BASIS, 14 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | // See the License for the specific language governing permissions and 16 | // limitations under the License. 17 | // 18 | 19 | package main 20 | 21 | import ( 22 | "encoding/base64" 23 | "encoding/json" 24 | "fmt" 25 | "io" 26 | "io/ioutil" 27 | "os" 28 | "os/exec" 29 | "runtime" 30 | "strconv" 31 | "strings" 32 | 33 | "github.com/arduino/go-apt-client" 34 | dockerConfig "github.com/docker/cli/cli/config" 35 | "github.com/docker/docker/api/types" 36 | "github.com/docker/docker/api/types/container" 37 | "github.com/docker/docker/api/types/filters" 38 | "github.com/docker/docker/api/types/network" 39 | docker "github.com/docker/docker/client" 40 | mqtt "github.com/eclipse/paho.mqtt.golang" 41 | "github.com/pkg/errors" 42 | "github.com/shirou/gopsutil/host" 43 | "golang.org/x/net/context" 44 | ) 45 | 46 | // RunPayload Struct merges connector specific parameters with 47 | // docker API structures ContainerConfig, ContainerHostConfig, NetworkNetworkingConfig 48 | type RunPayload struct { 49 | ImageName string `json:"image"` 50 | ContainerName string `json:"name"` 51 | ContainerID string `json:"id"` 52 | Action string `json:"action"` 53 | User string `json:"user,omitempty"` 54 | Password string `json:"password,omitempty"` 55 | SaveRegistryCredentials bool `json:"save_registry_credentials,omitempty"` 56 | ContainerConfig container.Config `json:"container_config,omitempty"` 57 | ContainerHostConfig container.HostConfig `json:"host_config,omitempty"` 58 | NetworkNetworkingConfig network.NetworkingConfig `json:"networking_config,omitempty"` 59 | } 60 | 61 | type PsPayload struct { 62 | ContainerID string `json:"id,omitempty"` 63 | } 64 | 65 | type ImagesPayload struct { 66 | ImageName string `json:"name,omitempty"` 67 | } 68 | 69 | type ChangeNamePayload struct { 70 | ContainerID string `json:"id"` 71 | ContainerName string `json:"name"` 72 | } 73 | 74 | // ContainersPsEvent send info about "docker ps -a" command 75 | func (s *Status) ContainersPsEvent(client mqtt.Client, msg mqtt.Message) { 76 | psPayload := PsPayload{} 77 | err := json.Unmarshal(msg.Payload(), &psPayload) 78 | if err != nil { 79 | s.Error("/containers/ps", errors.Wrapf(err, "unmarshal %s", msg.Payload())) 80 | return 81 | } 82 | 83 | containerListOptions := types.ContainerListOptions{All: true} 84 | if psPayload.ContainerID != "" { 85 | containerListOptions.Filters = filters.NewArgs(filters.Arg("id", psPayload.ContainerID)) 86 | } 87 | 88 | containers, err := s.dockerClient.ContainerList(context.Background(), containerListOptions) 89 | if err != nil { 90 | s.Error("/containers/ps", fmt.Errorf("Json marshal result: %s", err)) 91 | return 92 | } 93 | 94 | data, err := json.Marshal(containers) 95 | if err != nil { 96 | s.Error("/containers/ps", fmt.Errorf("Json marsahl result: %s", err)) 97 | return 98 | } 99 | 100 | s.SendInfo(s.topicPertinence+"/containers/ps", string(data)+"\n") 101 | } 102 | 103 | // ContainersListImagesEvent implements docker images 104 | func (s *Status) ContainersListImagesEvent(client mqtt.Client, msg mqtt.Message) { 105 | imagesPayload := ImagesPayload{} 106 | err := json.Unmarshal(msg.Payload(), &imagesPayload) 107 | if err != nil { 108 | s.Error("/containers/images", errors.Wrapf(err, "unmarshal %s", msg.Payload())) 109 | return 110 | } 111 | 112 | imageListOptions := types.ImageListOptions{All: true} 113 | if imagesPayload.ImageName != "" { 114 | imageListOptions.Filters = filters.NewArgs(filters.Arg("reference", imagesPayload.ImageName)) 115 | } 116 | 117 | images, err := s.dockerClient.ImageList(context.Background(), imageListOptions) 118 | if err != nil { 119 | s.Error("/containers/images", fmt.Errorf("images result: %s", err)) 120 | return 121 | } 122 | 123 | // Send result 124 | data, err := json.Marshal(images) 125 | if err != nil { 126 | s.Error("/containers/images", fmt.Errorf("Json marsahl result: %s", err)) 127 | return 128 | } 129 | 130 | s.SendInfo(s.topicPertinence+"/containers/images", string(data)+"\n") 131 | } 132 | 133 | // ContainersRenameEvent implements docker rename 134 | func (s *Status) ContainersRenameEvent(client mqtt.Client, msg mqtt.Message) { 135 | cnPayload := ChangeNamePayload{} 136 | err := json.Unmarshal(msg.Payload(), &cnPayload) 137 | if err != nil { 138 | s.Error("/containers/rename", errors.Wrapf(err, "unmarshal %s", msg.Payload())) 139 | return 140 | } 141 | err = s.dockerClient.ContainerRename(context.Background(), cnPayload.ContainerID, cnPayload.ContainerName) 142 | if err != nil { 143 | s.Error("/containers/rename", fmt.Errorf("rename result: %s", err)) 144 | return 145 | } 146 | 147 | // Send result 148 | data, err := json.Marshal(cnPayload) 149 | if err != nil { 150 | s.Error("/containers/rename", fmt.Errorf("Json marsahl result: %s", err)) 151 | return 152 | } 153 | 154 | s.SendInfo(s.topicPertinence+"/containers/rename", string(data)+"\n") 155 | } 156 | 157 | // ContainersActionEvent implements docker container action like run, start and stop, remove 158 | func (s *Status) ContainersActionEvent(client mqtt.Client, msg mqtt.Message) { 159 | 160 | runParams := RunPayload{} 161 | err := json.Unmarshal(msg.Payload(), &runParams) 162 | if err != nil { 163 | s.Error("/containers/action", errors.Wrapf(err, "unmarshal %s", msg.Payload())) 164 | return 165 | } 166 | 167 | runResponse := RunPayload{ 168 | ImageName: runParams.ImageName, 169 | ContainerName: runParams.ContainerName, 170 | ContainerID: runParams.ContainerID, 171 | Action: runParams.Action, 172 | } 173 | 174 | ctx := context.Background() 175 | switch runParams.Action { 176 | case "run": 177 | // check if user and passw are present in order to add auth 178 | // remember that the imageName should provide also the registry endpoint 179 | // i.e 6435543362.dkr.ecr.eu-east-1.amazonaws.com/redis:latest 180 | // the default is docker.io/library/redis:latest 181 | pullOpts, authConfig, errConf := ConfigureRegistryAuth(runParams) 182 | if errConf != nil { 183 | fmt.Println(errConf) 184 | break 185 | } 186 | 187 | if authConfig != nil { 188 | _, err = s.dockerClient.RegistryLogin(ctx, *authConfig) 189 | if err != nil { 190 | ClearRegistryAuth(runParams) 191 | s.Error("/containers/action", fmt.Errorf("auth test failed: %s", err)) 192 | return 193 | } 194 | } 195 | out, errPull := s.dockerClient.ImagePull(ctx, runParams.ImageName, pullOpts) 196 | if errPull != nil { 197 | s.Error("/containers/action", fmt.Errorf("image pull result: %s", errPull)) 198 | return 199 | } 200 | // waiting the complete download of the image 201 | _, err = io.Copy(ioutil.Discard, out) 202 | if err != nil { 203 | fmt.Println(err) 204 | } 205 | 206 | defer out.Close() 207 | fmt.Fprintf(os.Stdout, "Successfully downloaded image: %s\n", runParams.ImageName) 208 | 209 | // overwrite imagename in container.Config 210 | runParams.ContainerConfig.Image = runParams.ImageName 211 | // by default bind all the exposed ports via PublishAllPorts if the field PortBindings is empty 212 | // note that in this case docker decide the host port an the port changes if the container is restarted 213 | if len(runParams.ContainerHostConfig.PortBindings) == 0 { 214 | runParams.ContainerHostConfig.PublishAllPorts = true 215 | } 216 | 217 | resp, errCreate := s.dockerClient.ContainerCreate(ctx, &runParams.ContainerConfig, &runParams.ContainerHostConfig, 218 | &runParams.NetworkNetworkingConfig, runParams.ContainerName) 219 | 220 | if errCreate != nil { 221 | s.Error("/containers/action", fmt.Errorf("container create result: %s", errCreate)) 222 | return 223 | } 224 | 225 | if err = s.dockerClient.ContainerStart(ctx, resp.ID, types.ContainerStartOptions{}); err != nil { 226 | s.Error("/containers/action", fmt.Errorf("container start result: %s", err)) 227 | return 228 | } 229 | runResponse.ContainerID = resp.ID 230 | fmt.Fprintf(os.Stdout, "Successfully started container %s from Image: %s\n", resp.ID, runParams.ImageName) 231 | 232 | case "stop": 233 | if err = s.dockerClient.ContainerStop(ctx, runParams.ContainerID, nil); err != nil { 234 | s.Error("/containers/action", fmt.Errorf("container action result: %s", err)) 235 | return 236 | } 237 | fmt.Fprintf(os.Stdout, "Successfully stopped container %s\n", runParams.ContainerID) 238 | 239 | case "start": 240 | if err = s.dockerClient.ContainerStart(ctx, runParams.ContainerID, types.ContainerStartOptions{}); err != nil { 241 | s.Error("/containers/action", fmt.Errorf("container action result: %s", err)) 242 | return 243 | } 244 | fmt.Fprintf(os.Stdout, "Successfully started container %s\n", runParams.ContainerID) 245 | 246 | case "remove": 247 | forceAllOption := types.ContainerRemoveOptions{ 248 | Force: true, 249 | RemoveLinks: false, 250 | RemoveVolumes: true, 251 | } 252 | 253 | if err = s.dockerClient.ContainerRemove(ctx, runParams.ContainerID, forceAllOption); err != nil { 254 | s.Error("/containers/action", fmt.Errorf("container remove result: %s", err)) 255 | return 256 | } 257 | fmt.Fprintf(os.Stdout, "Successfully removed container %s\n", runParams.ContainerID) 258 | // implements docker image prune -a that removes all images not associated to a container 259 | forceAllImagesArg, _ := filters.FromJSON(`{"dangling": false}`) 260 | if _, errPrune := s.dockerClient.ImagesPrune(ctx, forceAllImagesArg); err != nil { 261 | s.Error("/containers/action", fmt.Errorf("images prune result: %s", errPrune)) 262 | return 263 | } 264 | fmt.Fprintf(os.Stdout, "Successfully pruned container images\n") 265 | 266 | default: 267 | s.Error("/containers/action", fmt.Errorf("container command %s not found", runParams.Action)) 268 | return 269 | } 270 | 271 | // Send result 272 | data, err := json.Marshal(runResponse) 273 | if err != nil { 274 | s.Error("/containers/action", fmt.Errorf("Json marshal result: %s", err)) 275 | return 276 | } 277 | 278 | s.SendInfo("/containers/action", string(data)+"\n") 279 | } 280 | 281 | // ConfigureRegistryAuth manages registry authentication usage flow 282 | func ConfigureRegistryAuth(runParams RunPayload) (types.ImagePullOptions, *types.AuthConfig, error) { 283 | var authConfig *types.AuthConfig 284 | var err error 285 | pullOpts := types.ImagePullOptions{} 286 | imageRegistryEndpoint := strings.Split(runParams.ImageName, "/")[0] 287 | if runParams.User != "" && runParams.Password != "" { 288 | // user provided credentials 289 | authConfig = &types.AuthConfig{ 290 | Username: runParams.User, 291 | Password: runParams.Password, 292 | ServerAddress: imageRegistryEndpoint, 293 | } 294 | encodedJSON, errMars := json.Marshal(authConfig) 295 | if errMars != nil { 296 | return pullOpts, authConfig, errMars 297 | } 298 | authStr := base64.URLEncoding.EncodeToString(encodedJSON) 299 | pullOpts = types.ImagePullOptions{RegistryAuth: authStr} 300 | // if user requested save, do the save of the credentials in ~/.docker/config.json in the docker standard way 301 | if runParams.SaveRegistryCredentials { 302 | loadedConfigFile, errLoad := dockerConfig.Load(dockerConfig.Dir()) 303 | if errLoad != nil { 304 | return pullOpts, authConfig, errLoad 305 | } 306 | loadedConfigFile.AuthConfigs[authConfig.ServerAddress] = *authConfig 307 | err = loadedConfigFile.Save() 308 | if err != nil { 309 | fmt.Println(err) 310 | } 311 | } 312 | 313 | } else { 314 | //user did not provided credentials, search in config 315 | loadedConfigFile, errLoad := dockerConfig.Load(dockerConfig.Dir()) 316 | if errLoad != nil { 317 | return pullOpts, authConfig, err 318 | } 319 | loadedAuthConfig, errConf := loadedConfigFile.GetAuthConfig(imageRegistryEndpoint) 320 | if errConf != nil { 321 | return pullOpts, authConfig, err 322 | } 323 | authConfig = &loadedAuthConfig 324 | encodedJSON, errMar := json.Marshal(loadedAuthConfig) 325 | if errMar != nil { 326 | return pullOpts, authConfig, err 327 | } 328 | authStr := base64.URLEncoding.EncodeToString(encodedJSON) 329 | pullOpts = types.ImagePullOptions{RegistryAuth: authStr} 330 | 331 | } 332 | return pullOpts, authConfig, err 333 | } 334 | 335 | // ClearRegistryAuth removes credential for a certain registry from docker config 336 | func ClearRegistryAuth(runParams RunPayload) { 337 | loadedConfigFile, err := dockerConfig.Load(dockerConfig.Dir()) 338 | if err != nil { 339 | return 340 | } 341 | imageRegistryEndpoint := strings.Split(runParams.ImageName, "/")[0] 342 | delete(loadedConfigFile.AuthConfigs, imageRegistryEndpoint) 343 | err = loadedConfigFile.Save() 344 | if err != nil { 345 | fmt.Println(err) 346 | } 347 | } 348 | 349 | // checkAndInstallDocker implements steps from https://docs.docker.com/install/linux/docker-ce/ubuntu/ 350 | func checkAndInstallDocker() { 351 | cli, err := docker.NewClientWithOpts(docker.WithVersion("1.38")) 352 | defer func() { 353 | err = cli.Close() 354 | if err != nil { 355 | return 356 | } 357 | }() 358 | if cli != nil { 359 | _, err = cli.ContainerList(context.Background(), types.ContainerListOptions{}) 360 | if err != nil { 361 | fmt.Println("Docker daemon not found!") 362 | } 363 | } 364 | if err != nil { 365 | //returns platform string, family string, version string, err error 366 | platform, family, version, err := host.PlatformInformation() 367 | distroVer, cerr := strconv.Atoi(strings.Replace(version, ".", "", -1)) 368 | if err != nil && cerr != nil { 369 | fmt.Println("Failed to fetch system info") 370 | } 371 | fmt.Printf("Fetched system info: %s %s %s on arch: %s\n", platform, family, version, runtime.GOARCH) 372 | if runtime.GOARCH == "amd64" { 373 | if platform == "ubuntu" { 374 | if distroVer >= 1604 { 375 | installDockerCEOnXenialAndNewer() 376 | } 377 | } 378 | } else if runtime.GOARCH == "arm" { 379 | if platform == "raspbian" { 380 | installDockerCEViaConvenienceScript() 381 | } 382 | } 383 | } 384 | } 385 | 386 | func installDockerCEViaConvenienceScript() { 387 | curlString := "curl -fsSL get.docker.com -o get-docker.sh" 388 | curlCmd := exec.Command("bash", "-c", curlString) 389 | if out, errCmd := curlCmd.CombinedOutput(); errCmd != nil { 390 | fmt.Println("Failed to Download Docker CE Convenience Script Installer:") 391 | fmt.Println(string(out)) 392 | } 393 | 394 | installCmd := exec.Command("sh", "get-docker.sh") 395 | if out, err := installCmd.CombinedOutput(); err != nil { 396 | fmt.Println("Failed to Run Docker CE Convenience Script Installer:") 397 | fmt.Println(string(out)) 398 | } 399 | } 400 | 401 | func installDockerCEOnXenialAndNewer() { 402 | // dpkg --configure -a for prevent block of installation 403 | dpkgCmd := exec.Command("dpkg", "--configure", "-a") 404 | if out, err := dpkgCmd.CombinedOutput(); err != nil { 405 | fmt.Println("Failed to reconfigure dpkg:") 406 | fmt.Println(string(out)) 407 | } 408 | 409 | _, err := apt.CheckForUpdates() 410 | if err != nil { 411 | fmt.Println(err) 412 | return 413 | } 414 | 415 | dockerPrerequisitesPackages := []*apt.Package{ 416 | &apt.Package{Name: "apt-transport-https"}, 417 | &apt.Package{Name: "ca-certificates"}, 418 | &apt.Package{Name: "curl"}, 419 | &apt.Package{Name: "software-properties-common"}, 420 | } 421 | for _, pac := range dockerPrerequisitesPackages { 422 | if out, errInstall := apt.Install(pac); errInstall != nil { 423 | fmt.Println("Failed to install: ", pac.Name) 424 | fmt.Println(string(out)) 425 | return 426 | } 427 | } 428 | 429 | curlString := "curl -fsSL https://download.docker.com/linux/ubuntu/gpg | apt-key add -" 430 | curlCmd := exec.Command("bash", "-c", curlString) 431 | if out, errCmd := curlCmd.CombinedOutput(); errCmd != nil { 432 | fmt.Println("Failed to add Docker’s official GPG key:") 433 | fmt.Println(string(out)) 434 | } 435 | 436 | repoString := `add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable"` 437 | repoCmd := exec.Command("bash", "-c", repoString) 438 | if out, errCmd := repoCmd.CombinedOutput(); errCmd != nil { 439 | fmt.Println("Failed to set up the stable docker repository:") 440 | fmt.Println(string(out)) 441 | } 442 | 443 | _, err = apt.CheckForUpdates() 444 | if err != nil { 445 | fmt.Println(err) 446 | return 447 | } 448 | 449 | toInstall := &apt.Package{Name: "docker-ce"} 450 | if out, err := apt.Install(toInstall); err != nil { 451 | fmt.Println("Failed to install docker-ce:") 452 | fmt.Println(string(out)) 453 | return 454 | } 455 | 456 | sysCmd := exec.Command("systemctl", "enable", "docker") 457 | if out, err := sysCmd.CombinedOutput(); err != nil { 458 | fmt.Println("Failed to systemctl enable docker:") 459 | fmt.Println(string(out)) 460 | } 461 | } 462 | -------------------------------------------------------------------------------- /handlers_containers_functional_test.go: -------------------------------------------------------------------------------- 1 | // +build functional 2 | 3 | package main 4 | 5 | import ( 6 | "encoding/json" 7 | "io" 8 | "io/ioutil" 9 | "log" 10 | "os" 11 | "os/exec" 12 | "strings" 13 | "testing" 14 | "time" 15 | 16 | "github.com/docker/docker/api/types" 17 | "github.com/docker/docker/api/types/container" 18 | "github.com/docker/docker/api/types/filters" 19 | docker "github.com/docker/docker/client" 20 | mqtt "github.com/eclipse/paho.mqtt.golang" 21 | "github.com/pkg/errors" 22 | "github.com/stretchr/testify/assert" 23 | "golang.org/x/net/context" 24 | ) 25 | 26 | type testStatus struct { 27 | appStatus *Status 28 | ui *MqttTestClient 29 | } 30 | 31 | var ts testStatus 32 | 33 | func TestMain(m *testing.M) { 34 | os.Exit(setupAndRun(m)) 35 | } 36 | 37 | func setupAndRun(m *testing.M) int { 38 | ts.ui = NewMqttTestClientLocal() 39 | defer ts.ui.Close() 40 | 41 | ts.appStatus = NewStatus(program{}.Config, nil, nil, "") 42 | ts.appStatus.dockerClient, _ = docker.NewClientWithOpts(docker.WithVersion("1.38")) 43 | ts.appStatus.mqttClient = mqtt.NewClient(mqtt.NewClientOptions().AddBroker("tcp://localhost:1883").SetClientID("arduino-connector")) 44 | 45 | if token := ts.appStatus.mqttClient.Connect(); token.Wait() && token.Error() != nil { 46 | log.Fatal(token.Error()) 47 | } 48 | defer ts.appStatus.mqttClient.Disconnect(100) 49 | 50 | return m.Run() 51 | } 52 | 53 | func execCmd(cmd string) []string { 54 | c := exec.Command("bash", "-c", cmd) 55 | out, err := c.CombinedOutput() 56 | if err != nil { 57 | return []string{} 58 | } 59 | 60 | lines := strings.Split(string(out), "\n") 61 | return lines[1 : len(lines)-1] 62 | } 63 | 64 | func TestDockerPsApi(t *testing.T) { 65 | subscribeTopic(ts.appStatus.mqttClient, "0", "/containers/ps/post", ts.appStatus, ts.appStatus.ContainersPsEvent, false) 66 | resp := ts.ui.MqttSendAndReceiveTimeout(t, "/containers/ps", "{}", 50*time.Millisecond) 67 | 68 | lines := execCmd("docker ps -a") 69 | 70 | // Take json without INFO tag 71 | resp = strings.TrimPrefix(resp, "INFO: ") 72 | resp = strings.TrimSuffix(resp, "\n\n") 73 | var result []types.Container 74 | if err := json.Unmarshal([]byte(resp), &result); err != nil { 75 | t.Fatal(err) 76 | } 77 | 78 | assert.Equal(t, len(result), len(lines)) 79 | for i, line := range lines { 80 | containerId := strings.Fields(line)[0] 81 | assert.True(t, strings.HasPrefix(result[i].ID, containerId)) 82 | } 83 | } 84 | 85 | func TestDockerListImagesApi(t *testing.T) { 86 | subscribeTopic(ts.appStatus.mqttClient, "0", "/containers/images/post", ts.appStatus, ts.appStatus.ContainersListImagesEvent, false) 87 | resp := ts.ui.MqttSendAndReceiveTimeout(t, "/containers/images", "{}", 50*time.Millisecond) 88 | 89 | lines := execCmd("docker images -a") 90 | 91 | // Take json without INFO tag 92 | resp = strings.TrimPrefix(resp, "INFO: ") 93 | resp = strings.TrimSuffix(resp, "\n\n") 94 | var result []types.ImageSummary 95 | if err := json.Unmarshal([]byte(resp), &result); err != nil { 96 | t.Fatal(err) 97 | } 98 | 99 | assert.Equal(t, len(result), len(lines)) 100 | } 101 | 102 | func TestDockerRenameApi(t *testing.T) { 103 | // download an alpine image from library to use as test 104 | reader, err := ts.appStatus.dockerClient.ImagePull(context.Background(), "docker.io/library/alpine", types.ImagePullOptions{}) 105 | if err != nil { 106 | t.Fatal(err) 107 | } 108 | _, err = io.Copy(ioutil.Discard, reader) 109 | if err != nil { 110 | t.Error(err) 111 | } 112 | 113 | defer func() { 114 | reader.Close() 115 | 116 | filters := filters.NewArgs(filters.Arg("reference", "alpine")) 117 | images, errImagels := ts.appStatus.dockerClient.ImageList(context.Background(), types.ImageListOptions{Filters: filters}) 118 | if errImagels != nil { 119 | t.Fatal(errImagels) 120 | } 121 | 122 | if _, errImageRemove := ts.appStatus.dockerClient.ImageRemove(context.Background(), images[0].ID, types.ImageRemoveOptions{}); errImageRemove != nil { 123 | t.Fatal(errImageRemove) 124 | } 125 | }() 126 | 127 | // create a test container from downloaded image 128 | createContResp, err := ts.appStatus.dockerClient.ContainerCreate(context.Background(), &container.Config{ 129 | Image: "alpine", 130 | Cmd: []string{"echo", "hello world"}, 131 | }, nil, nil, "") 132 | if err != nil { 133 | t.Fatal(err) 134 | } 135 | defer func() { 136 | if err = ts.appStatus.dockerClient.ContainerRemove(context.Background(), createContResp.ID, types.ContainerRemoveOptions{}); err != nil { 137 | t.Fatal(err) 138 | } 139 | }() 140 | 141 | cnPayload := ChangeNamePayload{ 142 | ContainerID: createContResp.ID, 143 | ContainerName: "newname", 144 | } 145 | data, err := json.Marshal(cnPayload) 146 | if err != nil { 147 | t.Fatal(err) 148 | } 149 | 150 | subscribeTopic(ts.appStatus.mqttClient, "0", "/containers/rename/post", ts.appStatus, ts.appStatus.ContainersRenameEvent, true) 151 | resp := ts.ui.MqttSendAndReceiveTimeout(t, "/containers/rename", string(data), 250*time.Millisecond) 152 | 153 | lines := execCmd("docker container ls -a") 154 | 155 | // Take json without INFO tag 156 | resp = strings.TrimPrefix(resp, "INFO: ") 157 | resp = strings.TrimSuffix(resp, "\n\n") 158 | var result ChangeNamePayload 159 | if err := json.Unmarshal([]byte(resp), &result); err != nil { 160 | t.Fatal(err) 161 | } 162 | 163 | assert.Equal(t, cnPayload, result) 164 | 165 | // find test container through its ID and check its name 166 | for _, line := range lines { 167 | tokens := strings.Fields(line) 168 | if strings.HasPrefix(result.ContainerID, tokens[0]) { 169 | assert.Equal(t, result.ContainerName, tokens[len(tokens)-1]) 170 | return 171 | } 172 | } 173 | 174 | t.Fatalf("no container with ID %s has been found\n", result.ContainerID) 175 | } 176 | 177 | func TestDockerActionRunApi(t *testing.T) { 178 | subscribeTopic(ts.appStatus.mqttClient, "0", "/containers/action/post", ts.appStatus, ts.appStatus.ContainersActionEvent, false) 179 | testContainer := "test-container" 180 | payload := map[string]interface{}{"action": "run", "image": "alpine", "name": testContainer} 181 | data, err := json.Marshal(payload) 182 | if err != nil { 183 | t.Error(err) 184 | } 185 | 186 | ts.ui.MqttSendAndReceiveTimeout(t, "/containers/action", string(data), 20*time.Second) 187 | 188 | lines := execCmd("docker ps") 189 | 190 | foundTestContainerRunning := false 191 | for _, l := range lines { 192 | if strings.Contains(l, "alpine") && strings.Contains(lines[0], testContainer) { 193 | foundTestContainerRunning = true 194 | } 195 | } 196 | 197 | assert.True(t, foundTestContainerRunning) 198 | 199 | defer func() { 200 | timeout := 1 * time.Millisecond 201 | err = ts.appStatus.dockerClient.ContainerStop(context.Background(), testContainer, &timeout) 202 | if err != nil { 203 | t.Error(err) 204 | } 205 | 206 | err = ts.appStatus.dockerClient.ContainerRemove(context.Background(), testContainer, types.ContainerRemoveOptions{}) 207 | if err != nil { 208 | t.Error(err) 209 | } 210 | 211 | _, err = ts.appStatus.dockerClient.ImageRemove(context.Background(), "alpine", types.ImageRemoveOptions{}) 212 | if err != nil { 213 | t.Error(err) 214 | } 215 | }() 216 | } 217 | 218 | func TestDockerActionStopApi(t *testing.T) { 219 | subscribeTopic(ts.appStatus.mqttClient, "0", "/containers/action/post", ts.appStatus, ts.appStatus.ContainersActionEvent, false) 220 | testContainer := "test-container" 221 | 222 | reader, err := ts.appStatus.dockerClient.ImagePull(context.Background(), "alpine", types.ImagePullOptions{}) 223 | if err != nil { 224 | t.Error(err) 225 | } 226 | 227 | _, err = io.Copy(ioutil.Discard, reader) 228 | if err != nil { 229 | t.Error(err) 230 | } 231 | 232 | createContResp, errCreate := ts.appStatus.dockerClient.ContainerCreate(context.Background(), &container.Config{ 233 | Image: "alpine", 234 | Cmd: []string{"echo", "hello world"}, 235 | }, nil, nil, "") 236 | if errCreate != nil { 237 | t.Error(errCreate) 238 | } 239 | 240 | err = ts.appStatus.dockerClient.ContainerRename(context.Background(), createContResp.ID, testContainer) 241 | if err != nil { 242 | t.Error(err) 243 | } 244 | 245 | err = ts.appStatus.dockerClient.ContainerStart(context.Background(), testContainer, types.ContainerStartOptions{}) 246 | if err != nil { 247 | t.Error(err) 248 | } 249 | 250 | payload := map[string]interface{}{"action": "stop", "image": "alpine", "id": createContResp.ID} 251 | data, err := json.Marshal(payload) 252 | if err != nil { 253 | t.Error(err) 254 | } 255 | 256 | ts.ui.MqttSendAndReceiveTimeout(t, "/containers/action", string(data), 20*time.Second) 257 | 258 | lines := execCmd("docker ps") 259 | foundTestContainerRunning := false 260 | for _, l := range lines { 261 | if strings.Contains(l, "alpine") && strings.Contains(lines[0], testContainer) { 262 | foundTestContainerRunning = true 263 | } 264 | } 265 | 266 | assert.False(t, foundTestContainerRunning) 267 | 268 | defer func() { 269 | err = ts.appStatus.dockerClient.ContainerRemove(context.Background(), testContainer, types.ContainerRemoveOptions{}) 270 | if err != nil { 271 | t.Error(err) 272 | } 273 | 274 | _, err = ts.appStatus.dockerClient.ImageRemove(context.Background(), "alpine", types.ImageRemoveOptions{}) 275 | if err != nil { 276 | t.Error(err) 277 | } 278 | }() 279 | } 280 | 281 | func TestDockerActionStartApi(t *testing.T) { 282 | subscribeTopic(ts.appStatus.mqttClient, "0", "/containers/action/post", ts.appStatus, ts.appStatus.ContainersActionEvent, false) 283 | testContainer := "test-container" 284 | 285 | reader, err := ts.appStatus.dockerClient.ImagePull(context.Background(), "alpine", types.ImagePullOptions{}) 286 | if err != nil { 287 | t.Error(err) 288 | } 289 | 290 | _, err = io.Copy(ioutil.Discard, reader) 291 | if err != nil { 292 | t.Error(err) 293 | } 294 | 295 | createContResp, errCreate := ts.appStatus.dockerClient.ContainerCreate(context.Background(), &container.Config{ 296 | Image: "alpine", 297 | Cmd: []string{"echo", "hello world"}, 298 | }, nil, nil, "") 299 | if errCreate != nil { 300 | t.Error(errCreate) 301 | } 302 | 303 | err = ts.appStatus.dockerClient.ContainerRename(context.Background(), createContResp.ID, testContainer) 304 | if err != nil { 305 | t.Error(err) 306 | } 307 | 308 | payload := map[string]interface{}{"action": "start", "image": "alpine", "id": createContResp.ID} 309 | data, err := json.Marshal(payload) 310 | if err != nil { 311 | t.Error(err) 312 | } 313 | 314 | ts.ui.MqttSendAndReceiveTimeout(t, "/containers/action", string(data), 20*time.Second) 315 | 316 | lines := execCmd("docker ps") 317 | foundTestContainerRunning := false 318 | for _, l := range lines { 319 | if strings.Contains(l, "alpine") && strings.Contains(lines[0], testContainer) { 320 | foundTestContainerRunning = true 321 | } 322 | } 323 | 324 | assert.True(t, foundTestContainerRunning) 325 | 326 | defer func() { 327 | timeout := 1 * time.Millisecond 328 | err = ts.appStatus.dockerClient.ContainerStop(context.Background(), testContainer, &timeout) 329 | if err != nil { 330 | t.Error(err) 331 | } 332 | 333 | err = ts.appStatus.dockerClient.ContainerRemove(context.Background(), testContainer, types.ContainerRemoveOptions{}) 334 | if err != nil { 335 | t.Error(err) 336 | } 337 | 338 | _, err = ts.appStatus.dockerClient.ImageRemove(context.Background(), "alpine", types.ImageRemoveOptions{}) 339 | if err != nil { 340 | t.Error(err) 341 | } 342 | }() 343 | } 344 | 345 | func TestDockerActionRemoveApi(t *testing.T) { 346 | subscribeTopic(ts.appStatus.mqttClient, "0", "/containers/action/post", ts.appStatus, ts.appStatus.ContainersActionEvent, false) 347 | 348 | reader, err := ts.appStatus.dockerClient.ImagePull(context.Background(), "alpine", types.ImagePullOptions{}) 349 | if err != nil { 350 | t.Error(err) 351 | } 352 | 353 | _, err = io.Copy(ioutil.Discard, reader) 354 | if err != nil { 355 | t.Error(err) 356 | } 357 | 358 | createContResp, errCreate := ts.appStatus.dockerClient.ContainerCreate(context.Background(), &container.Config{ 359 | Image: "alpine", 360 | Cmd: []string{"echo", "hello world"}, 361 | }, nil, nil, "") 362 | if errCreate != nil { 363 | t.Error(errCreate) 364 | } 365 | 366 | payload := map[string]interface{}{"action": "remove", "image": "alpine", "id": createContResp.ID} 367 | data, err := json.Marshal(payload) 368 | if err != nil { 369 | t.Error(err) 370 | } 371 | 372 | // Use a very large timeout to avoid failing the test on GitHub Actions environment 373 | ts.ui.MqttSendAndReceiveTimeout(t, "/containers/action", string(data), 300*time.Second) 374 | 375 | lines := execCmd("docker ps -a") 376 | foundTestContainer := false 377 | for _, l := range lines { 378 | if strings.Contains(l, createContResp.ID) { 379 | foundTestContainer = true 380 | } 381 | } 382 | 383 | assert.False(t, foundTestContainer) 384 | } 385 | 386 | type MqttTokenMock struct { 387 | returnErr bool 388 | } 389 | 390 | func (t *MqttTokenMock) Wait() bool { 391 | return true 392 | } 393 | 394 | func (t *MqttTokenMock) WaitTimeout(time.Duration) bool { 395 | return true 396 | } 397 | 398 | func (t *MqttTokenMock) Error() error { 399 | if t.returnErr { 400 | return errors.New("test err") 401 | } 402 | 403 | return nil 404 | } 405 | 406 | type MqttClientMock struct { 407 | errPublished string 408 | } 409 | 410 | func (c *MqttClientMock) IsConnected() bool { 411 | return false 412 | } 413 | 414 | func (c *MqttClientMock) IsConnectionOpen() bool { 415 | return true 416 | } 417 | 418 | func (c *MqttClientMock) Connect() mqtt.Token { 419 | return nil 420 | } 421 | 422 | func (c *MqttClientMock) Disconnect(quiesce uint) { 423 | } 424 | 425 | func (c *MqttClientMock) Publish(topic string, qos byte, retained bool, payload interface{}) mqtt.Token { 426 | payloadStr := payload.(string) 427 | if strings.HasPrefix(payloadStr, "INFO") { 428 | return &MqttTokenMock{returnErr: true} 429 | } 430 | 431 | c.errPublished = payloadStr 432 | return &MqttTokenMock{returnErr: false} 433 | } 434 | 435 | func (c *MqttClientMock) Subscribe(topic string, qos byte, callback mqtt.MessageHandler) mqtt.Token { 436 | return nil 437 | } 438 | 439 | func (c *MqttClientMock) SubscribeMultiple(filters map[string]byte, callback mqtt.MessageHandler) mqtt.Token { 440 | return nil 441 | } 442 | 443 | func (c *MqttClientMock) Unsubscribe(topics ...string) mqtt.Token { 444 | return nil 445 | } 446 | 447 | func (c *MqttClientMock) AddRoute(topic string, callback mqtt.MessageHandler) { 448 | } 449 | 450 | func (c *MqttClientMock) OptionsReader() mqtt.ClientOptionsReader { 451 | return mqtt.ClientOptionsReader{} 452 | } 453 | 454 | func TestDockerApiError(t *testing.T) { 455 | ts.appStatus.mqttClient = &MqttClientMock{} 456 | topic := "/container/ps" 457 | ts.appStatus.SendInfo(topic, "error") 458 | mockClient := ts.appStatus.mqttClient.(*MqttClientMock) 459 | assert.Equal(t, mockClient.errPublished, "ERROR: test err\n") 460 | } 461 | -------------------------------------------------------------------------------- /handlers_stats.go: -------------------------------------------------------------------------------- 1 | // 2 | // This file is part of arduino-connector 3 | // 4 | // Copyright (C) 2017-2018 Arduino AG (http://www.arduino.cc/) 5 | // 6 | // Licensed under the Apache License, Version 2.0 (the "License"); 7 | // you may not use this file except in compliance with the License. 8 | // You may obtain a copy of the License at 9 | // 10 | // http://www.apache.org/licenses/LICENSE-2.0 11 | // 12 | // Unless required by applicable law or agreed to in writing, software 13 | // distributed under the License is distributed on an "AS IS" BASIS, 14 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | // See the License for the specific language governing permissions and 16 | // limitations under the License. 17 | // 18 | 19 | package main 20 | 21 | import ( 22 | "encoding/json" 23 | "fmt" 24 | "os/exec" 25 | 26 | apt "github.com/arduino/go-apt-client" 27 | "github.com/arduino/go-system-stats/disk" 28 | "github.com/arduino/go-system-stats/mem" 29 | net "github.com/arduino/go-system-stats/network" 30 | mqtt "github.com/eclipse/paho.mqtt.golang" 31 | "github.com/pkg/errors" 32 | ) 33 | 34 | // WiFiEvent tries to connect to the specified wifi network 35 | func (s *Status) WiFiEvent(client mqtt.Client, msg mqtt.Message) { 36 | // try registering a new wifi network 37 | var info struct { 38 | SSID string `json:"ssid"` 39 | Password string `json:"password"` 40 | } 41 | err := json.Unmarshal(msg.Payload(), &info) 42 | if err != nil { 43 | s.Error("/wifi", errors.Wrapf(err, "unmarshal %s", msg.Payload())) 44 | return 45 | } 46 | err = net.AddWirelessConnection(info.SSID, info.Password) 47 | if err != nil { 48 | return 49 | } 50 | } 51 | 52 | // EthEvent tries to change IP/Netmask/DNS configuration of the wired connection 53 | func (s *Status) EthEvent(client mqtt.Client, msg mqtt.Message) { 54 | // try registering a new wifi network 55 | var info net.IPProxyConfig 56 | err := json.Unmarshal(msg.Payload(), &info) 57 | if err != nil { 58 | s.Error("/ethernet", errors.Wrapf(err, "unmarshal %s", msg.Payload())) 59 | return 60 | } 61 | err = net.AddWiredConnection(info) 62 | if err != nil { 63 | return 64 | } 65 | } 66 | 67 | func checkAndInstallNetworkManager() { 68 | _, err := net.GetNetworkStats() 69 | if err == nil { 70 | return 71 | } 72 | 73 | dpkgCmd := exec.Command("dpkg", "--configure", "-a") 74 | if out, err := dpkgCmd.CombinedOutput(); err != nil { 75 | fmt.Println("Failed to dpkg configure all:") 76 | fmt.Println(string(out)) 77 | } 78 | 79 | toInstall := &apt.Package{Name: "network-manager"} 80 | if out, err := apt.Install(toInstall); err != nil { 81 | fmt.Println("Failed to install network-manager:") 82 | fmt.Println(string(out)) 83 | return 84 | } 85 | cmd := exec.Command("/etc/init.d/network-manager", "start") 86 | if out, err := cmd.CombinedOutput(); err != nil { 87 | fmt.Println("Failed to start network-manager:") 88 | fmt.Println(string(out)) 89 | } 90 | } 91 | 92 | // StatsEvent sends statistics about resource used in the system (RAM, Disk, Network, etc...) 93 | func (s *Status) StatsEvent(client mqtt.Client, msg mqtt.Message) { 94 | // Gather all system data metrics 95 | memStats, err := mem.GetStats() 96 | if err != nil { 97 | s.Error("/stats", fmt.Errorf("Retrieving memory stats: %s", err)) 98 | } 99 | 100 | diskStats, err := disk.GetStats() 101 | if err != nil { 102 | s.Error("/stats", fmt.Errorf("Retrieving disk stats: %s", err)) 103 | } 104 | 105 | netStats, err := net.GetNetworkStats() 106 | if err != nil { 107 | s.Error("/stats", fmt.Errorf("Retrieving network stats: %s", err)) 108 | } 109 | 110 | type StatsPayload struct { 111 | Memory *mem.Stats `json:"memory"` 112 | Disk []*disk.FSStats `json:"disk"` 113 | Network *net.Stats `json:"network"` 114 | } 115 | 116 | info := StatsPayload{ 117 | Memory: memStats, 118 | Disk: diskStats, 119 | Network: netStats, 120 | } 121 | 122 | // Send result 123 | data, err := json.Marshal(info) 124 | if err != nil { 125 | s.Error("/stats", fmt.Errorf("Json marsahl result: %s", err)) 126 | return 127 | } 128 | 129 | //var out bytes.Buffer 130 | //json.Indent(&out, data, "", " ") 131 | //fmt.Println(string(out.Bytes())) 132 | 133 | s.Info("/stats", string(data)+"\n") 134 | } 135 | -------------------------------------------------------------------------------- /handlers_test.go: -------------------------------------------------------------------------------- 1 | // 2 | // This file is part of arduino-connector 3 | // 4 | // Copyright (C) 2017-2018 Arduino AG (http://www.arduino.cc/) 5 | // 6 | // Licensed under the Apache License, Version 2.0 (the "License"); 7 | // you may not use this file except in compliance with the License. 8 | // You may obtain a copy of the License at 9 | // 10 | // http://www.apache.org/licenses/LICENSE-2.0 11 | // 12 | // Unless required by applicable law or agreed to in writing, software 13 | // distributed under the License is distributed on an "AS IS" BASIS, 14 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | // See the License for the specific language governing permissions and 16 | // limitations under the License. 17 | // 18 | 19 | package main 20 | 21 | import ( 22 | "context" 23 | "fmt" 24 | "net/http" 25 | "os/exec" 26 | "strings" 27 | "testing" 28 | "time" 29 | 30 | "github.com/stretchr/testify/assert" 31 | ) 32 | 33 | // ExecAsVagrantSshCmd "wraps vagrant ssh -c 34 | func ExecAsVagrantSshCmd(command string) (string, error) { 35 | vagrantSSHCmd := fmt.Sprintf(`cd test && vagrant ssh -c "%s"`, command) 36 | cmd := exec.Command("bash", "-c", vagrantSSHCmd) 37 | out, err := cmd.CombinedOutput() 38 | if err != nil { 39 | return "", err 40 | } 41 | return string(out), nil 42 | } 43 | 44 | // tests 45 | func TestSketchProcessIsRunning(t *testing.T) { 46 | mqtt := NewMqttTestClient() 47 | defer mqtt.Close() 48 | sketchTopic := "upload" 49 | 50 | fs := http.FileServer(http.Dir("test/sketch_devops_integ_test")) 51 | http.DefaultServeMux = new(http.ServeMux) 52 | http.Handle("/", fs) 53 | 54 | srv := &http.Server{Addr: ":3000"} 55 | 56 | go func() { 57 | err := srv.ListenAndServe() 58 | if err != nil { 59 | fmt.Println(err) 60 | } 61 | }() 62 | 63 | sketchDownloadCommand := fmt.Sprintf(`{"token": "","url": "%s","name": "sketch_devops_integ_test.elf","id": "0774e17e-f60e-4562-b87d-18017b6ef3d2"}`, "http://10.0.2.2:3000/sketch_devops_integ_test.elf") 64 | responseSketchRun := mqtt.MqttSendAndReceiveSync(t, sketchTopic, sketchDownloadCommand) 65 | t.Log(responseSketchRun) 66 | 67 | assert.Equal(t, true, strings.Contains(responseSketchRun, "INFO: Sketch started with PID ")) 68 | pid := strings.TrimSuffix(strings.Split(responseSketchRun, "INFO: Sketch started with PID ")[1], "\n") 69 | outputMessage, err := ExecAsVagrantSshCmd(fmt.Sprintf("ps -p %s --no-headers", pid)) 70 | t.Log(outputMessage) 71 | 72 | if err != nil { 73 | t.Error(err) 74 | } 75 | assert.Equal(t, 1, len(strings.Split(strings.TrimSuffix(outputMessage, "\n"), "\n"))) 76 | ctx, cancelFunc := context.WithTimeout(context.Background(), 1*time.Second) 77 | defer cancelFunc() 78 | if err := srv.Shutdown(ctx); err != nil { 79 | t.Error(err) 80 | } 81 | } 82 | 83 | // tests 84 | func TestMaliciousSketchProcessIsNotRunning(t *testing.T) { 85 | mqtt := NewMqttTestClient() 86 | defer mqtt.Close() 87 | sketchTopic := "upload" 88 | 89 | fs := http.FileServer(http.Dir("test/sketch_devops_integ_test/sketch_devops_integ_test_malicious")) 90 | http.DefaultServeMux = new(http.ServeMux) 91 | http.Handle("/", fs) 92 | srv := &http.Server{Addr: ":3000"} 93 | 94 | go func() { 95 | err := srv.ListenAndServe() 96 | if err != nil { 97 | fmt.Println(err) 98 | } 99 | }() 100 | 101 | sketchDownloadCommand := fmt.Sprintf(`{"token": "","url": "%s","name": "sketch_devops_integ_test.elf","id": "0774e17e-f60e-4562-b87d-18017b6ef3d2"}`, "http://10.0.2.2:3000/sketch_devops_integ_test.elf") 102 | responseSketchRun := mqtt.MqttSendAndReceiveSync(t, sketchTopic, sketchDownloadCommand) 103 | t.Log(responseSketchRun) 104 | 105 | assert.Equal(t, true, strings.Contains(responseSketchRun, "ERROR: signature do not match")) 106 | ctx, cancelFunc := context.WithTimeout(context.Background(), 1*time.Second) 107 | defer cancelFunc() 108 | if err := srv.Shutdown(ctx); err != nil { 109 | t.Error(err) 110 | } 111 | } 112 | 113 | func TestSketchProcessHasConfigWhitelistedEnvVars(t *testing.T) { 114 | // see upload_dev_artifacts_on_s3.sh to see where env vars are passed to the config 115 | mqtt := NewMqttTestClient() 116 | defer mqtt.Close() 117 | 118 | //test connector config 119 | outputMessage, err := ExecAsVagrantSshCmd("sudo cat /root/arduino-connector.cfg") 120 | if err != nil { 121 | t.Error(err) 122 | } 123 | envString := outputMessage 124 | t.Log(envString) 125 | assert.Equal(t, true, strings.Contains(envString, "env_vars_to_load=HDDL_INSTALL_DIR=/opt/intel/computer_vision_sdk/inference_engine/external/hddl/,ENV_TEST_PATH=/tmp")) 126 | 127 | //test environment 128 | sketchTopic := "upload" 129 | 130 | fs := http.FileServer(http.Dir("test/sketch_env_integ_test")) 131 | http.DefaultServeMux = new(http.ServeMux) 132 | http.Handle("/", fs) 133 | 134 | srv := &http.Server{Addr: ":3000"} 135 | 136 | go func() { 137 | err = srv.ListenAndServe() 138 | if err != nil { 139 | fmt.Println(err) 140 | } 141 | }() 142 | 143 | sketchDownloadCommand := fmt.Sprintf(`{"token": "","url": "%s","name": "connector_env_var_test.bin","id": "0774e17e-f60e-4562-b87d-18017b6ef3d2"}`, "http://10.0.2.2:3000/connector_env_var_test.bin") 144 | responseSketchRun := mqtt.MqttSendAndReceiveSync(t, sketchTopic, sketchDownloadCommand) 145 | t.Log(responseSketchRun) 146 | 147 | assert.Equal(t, true, strings.Contains(responseSketchRun, "INFO: Sketch started with PID ")) 148 | 149 | outputMessage, err = ExecAsVagrantSshCmd("cat /tmp/printenv.out") 150 | if err != nil { 151 | t.Error(err) 152 | } 153 | 154 | envString = outputMessage 155 | t.Log(envString) 156 | 157 | assert.Equal(t, true, strings.Contains(envString, "HDDL_INSTALL_DIR=/opt/intel/computer_vision_sdk/inference_engine/external/hddl/")) 158 | assert.Equal(t, true, strings.Contains(envString, "ENV_TEST_PATH=/tmp")) 159 | ctx, cancelFunc := context.WithTimeout(context.Background(), 1*time.Second) 160 | defer cancelFunc() 161 | if err := srv.Shutdown(ctx); err != nil { 162 | t.Error(err) 163 | } 164 | 165 | } 166 | -------------------------------------------------------------------------------- /handlers_update.go: -------------------------------------------------------------------------------- 1 | // 2 | // This file is part of arduino-connector 3 | // 4 | // Copyright (C) 2017-2018 Arduino AG (http://www.arduino.cc/) 5 | // 6 | // Licensed under the Apache License, Version 2.0 (the "License"); 7 | // you may not use this file except in compliance with the License. 8 | // You may obtain a copy of the License at 9 | // 10 | // http://www.apache.org/licenses/LICENSE-2.0 11 | // 12 | // Unless required by applicable law or agreed to in writing, software 13 | // distributed under the License is distributed on an "AS IS" BASIS, 14 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | // See the License for the specific language governing permissions and 16 | // limitations under the License. 17 | // 18 | 19 | package main 20 | 21 | import ( 22 | "fmt" 23 | "log" 24 | "os/exec" 25 | "strings" 26 | 27 | "github.com/arduino/arduino-connector/updater" 28 | "github.com/kardianos/osext" 29 | ) 30 | 31 | // Update checks for updates on arduino-connector. If an update is 32 | // available it performs the upgrade and restart the connector. 33 | func (s *Status) Update(config Config) { 34 | 35 | path, err := osext.Executable() 36 | if err != nil { 37 | //c.JSON(500, gin.H{"error": err.Error()}) 38 | return 39 | } 40 | 41 | var up = &updater.Updater{ 42 | CurrentVersion: version, 43 | APIURL: config.updateURL, 44 | BinURL: config.updateURL, 45 | DiffURL: "", 46 | Dir: "update/", 47 | CmdName: config.appName, 48 | } 49 | 50 | err = up.BackgroundRun() 51 | 52 | if err != nil { 53 | return 54 | } 55 | 56 | //c.JSON(200, gin.H{"success": "Please wait a moment while the agent reboots itself"}) 57 | go restart(path) 58 | } 59 | 60 | func restart(path string) { 61 | log.Println("called restart", path) 62 | // relaunch ourself and exit 63 | // the relaunch works because we pass a cmdline in 64 | // that has serial-port-json-server only initialize 5 seconds later 65 | // which gives us time to exit and unbind from serial ports and TCP/IP 66 | // sockets like :8989 67 | log.Println("Starting new spjs process") 68 | 69 | // figure out current path of executable so we know how to restart 70 | // this process using osext 71 | exePath, err3 := osext.Executable() 72 | if err3 != nil { 73 | log.Printf("Error getting exe path using osext lib. err: %v\n", err3) 74 | } 75 | 76 | if path == "" { 77 | log.Printf("exePath using osext: %v\n", exePath) 78 | } else { 79 | exePath = path 80 | } 81 | 82 | exePath = strings.Trim(exePath, "\n") 83 | 84 | cmd := exec.Command(exePath) 85 | 86 | fmt.Println(cmd) 87 | 88 | err := cmd.Start() 89 | if err != nil { 90 | log.Printf("Got err restarting spjs: %v\n", err) 91 | } 92 | log.Fatal("Exited current spjs for restart") 93 | } 94 | -------------------------------------------------------------------------------- /heartbeat.go: -------------------------------------------------------------------------------- 1 | // 2 | // This file is part of arduino-connector 3 | // 4 | // Copyright (C) 2017-2018 Arduino AG (http://www.arduino.cc/) 5 | // 6 | // Licensed under the Apache License, Version 2.0 (the "License"); 7 | // you may not use this file except in compliance with the License. 8 | // You may obtain a copy of the License at 9 | // 10 | // http://www.apache.org/licenses/LICENSE-2.0 11 | // 12 | // Unless required by applicable law or agreed to in writing, software 13 | // distributed under the License is distributed on an "AS IS" BASIS, 14 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | // See the License for the specific language governing permissions and 16 | // limitations under the License. 17 | // 18 | 19 | package main 20 | 21 | import ( 22 | "fmt" 23 | "strconv" 24 | "time" 25 | 26 | "github.com/arduino/go-system-stats/system" 27 | ) 28 | 29 | type heartbeat struct { 30 | running bool 31 | send func(payload string) error 32 | } 33 | 34 | func newHeartbeat(sendFunction func(payload string) error) *heartbeat { 35 | res := &heartbeat{ 36 | running: true, 37 | send: sendFunction, 38 | } 39 | go res.run() 40 | return res 41 | } 42 | 43 | func (h *heartbeat) run() { 44 | for h.running { 45 | uptime, err := system.GetUptime() 46 | if err != nil { 47 | fmt.Println("Error getting uptime:", err) 48 | } 49 | payload := strconv.FormatFloat(uptime.Seconds(), 'f', 2, 64) 50 | if err := h.send(payload); err != nil { 51 | fmt.Println("Error sending heartbeat:", err) 52 | } 53 | time.Sleep(15 * time.Second) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /install.go: -------------------------------------------------------------------------------- 1 | // 2 | // This file is part of arduino-connector 3 | // 4 | // Copyright (C) 2017-2020 Arduino AG (http://www.arduino.cc/) 5 | // 6 | // Licensed under the Apache License, Version 2.0 (the "License"); 7 | // you may not use this file except in compliance with the License. 8 | // You may obtain a copy of the License at 9 | // 10 | // http://www.apache.org/licenses/LICENSE-2.0 11 | // 12 | // Unless required by applicable law or agreed to in writing, software 13 | // distributed under the License is distributed on an "AS IS" BASIS, 14 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | // See the License for the specific language governing permissions and 16 | // limitations under the License. 17 | // 18 | 19 | package main 20 | 21 | import ( 22 | "bytes" 23 | "context" 24 | "crypto/ecdsa" 25 | "crypto/elliptic" 26 | "crypto/rand" 27 | "crypto/rsa" 28 | "crypto/x509" 29 | "crypto/x509/pkix" 30 | "encoding/asn1" 31 | "encoding/json" 32 | "encoding/pem" 33 | "fmt" 34 | "io/ioutil" 35 | "net" 36 | "net/http" 37 | "os" 38 | "path/filepath" 39 | "strings" 40 | "time" 41 | 42 | "github.com/arduino/arduino-connector/auth" 43 | mqtt "github.com/eclipse/paho.mqtt.golang" 44 | "github.com/facchinm/service" 45 | "github.com/kardianos/osext" 46 | "github.com/pkg/errors" 47 | ) 48 | 49 | const ( 50 | rsaBits = 2048 51 | ) 52 | 53 | // Install installs the program as a service 54 | func install(s service.Service) { 55 | // InstallService 56 | err := s.Install() 57 | // TODO: implement a fallback strtegy if service installation fails 58 | check(err, "InstallService") 59 | } 60 | 61 | func createConfigFolder() error { 62 | err := os.Mkdir("/etc/arduino-connector/", 0755) 63 | if err != nil { 64 | return err 65 | } 66 | 67 | return nil 68 | } 69 | 70 | // Register creates the necessary certificates and configuration files 71 | func register(config Config, configFile, token string) { 72 | // Request token 73 | var err error 74 | if token == "" { 75 | token, err = deviceAuth(config.AuthURL, config.AuthClientID) 76 | check(err, "deviceAuth") 77 | } 78 | 79 | // Generate a Private Key and CSR 80 | csr := generateKeyAndCsr(config) 81 | 82 | // Request Certificate and service URL to iot service 83 | config = requestCertAndBrokerURL(csr, config, configFile, token) 84 | 85 | // Connect to MQTT and communicate back 86 | registerDeviceViaMQTT(config) 87 | 88 | fmt.Println("Setup completed") 89 | } 90 | 91 | func generateKeyAndCsr(config Config) []byte { 92 | // Create a private key 93 | certKeyPath := filepath.Join(config.CertPath, "certificate.key") 94 | fmt.Println("Generate private key to dump in: ", certKeyPath) 95 | key, err := generateKey("P256") 96 | check(err, "generateKey") 97 | 98 | keyOut, err := os.OpenFile(certKeyPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) 99 | check(err, "openKeyFile") 100 | 101 | err = pem.Encode(keyOut, pemBlockForKey(key)) 102 | if err != nil { 103 | fmt.Println(err) 104 | return []byte{} 105 | } 106 | 107 | err = keyOut.Close() 108 | check(err, "closeKeyFile") 109 | 110 | // Create a csr 111 | fmt.Println("Generate csr") 112 | csr, err := generateCsr(config.ID, key) 113 | check(err, "generateCsr") 114 | return csr 115 | } 116 | 117 | func requestCertAndBrokerURL(csr []byte, config Config, configFile, token string) Config { 118 | // Request a certificate 119 | certPemPath := filepath.Join(config.CertPath, "certificate.pem") 120 | fmt.Println("Request certificate to dump in: ", certPemPath) 121 | pem, err := requestCert(config.APIURL, config.ID, token, csr) 122 | check(err, "requestCert") 123 | 124 | err = ioutil.WriteFile(certPemPath, []byte(pem), 0600) 125 | check(err, "writeCertFile") 126 | 127 | // Request URL 128 | fmt.Println("Request mqtt url") 129 | config.URL, err = requestURL(config.APIURL, token) 130 | check(err, "requestURL") 131 | 132 | // Write the configuration 133 | fmt.Println("Write conf to ", configFile) 134 | data := config.String() 135 | err = ioutil.WriteFile(configFile, []byte(data), 0660) 136 | check(err, "WriteConf") 137 | 138 | return config 139 | } 140 | 141 | func registerDeviceViaMQTT(config Config) { 142 | // Connect to MQTT and communicate back 143 | certPemPath := filepath.Join(config.CertPath, "certificate.pem") 144 | certKeyPath := filepath.Join(config.CertPath, "certificate.key") 145 | 146 | fmt.Println("Check successful MQTT connection") 147 | client, err := setupMQTTConnection(certPemPath, certKeyPath, config.ID, config.URL, nil) 148 | check(err, "ConnectMQTT") 149 | 150 | err = registerDevice(client, config.ID) 151 | check(err, "RegisterDevice") 152 | 153 | client.Disconnect(100) 154 | fmt.Println("MQTT connection successful") 155 | 156 | } 157 | 158 | // Implements Auth0 device authentication flow: https://auth0.com/docs/flows/guides/device-auth/call-api-device-auth 159 | func deviceAuth(authURL, clientID string) (token string, err error) { 160 | auth.Init() 161 | code, err := auth.StartDeviceAuth(authURL, clientID) 162 | if err != nil { 163 | return "", err 164 | } 165 | 166 | fmt.Printf("Go to %s and confirm authentication\n", code.VerificationURIComplete) 167 | 168 | ticker := time.NewTicker(10 * time.Second) 169 | 170 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) 171 | 172 | // Loop until the user authenticated or the timeout hits 173 | Loop: 174 | for { 175 | select { 176 | case <-ctx.Done(): 177 | break Loop 178 | case <-ticker.C: 179 | var err error 180 | token, err = auth.CheckDeviceAuth(authURL, clientID, code.DeviceCode) 181 | if err == nil { 182 | cancel() 183 | } 184 | } 185 | } 186 | 187 | ticker.Stop() 188 | cancel() 189 | 190 | return token, nil 191 | } 192 | 193 | func generateKey(ecdsaCurve string) (interface{}, error) { 194 | switch ecdsaCurve { 195 | case "": 196 | return rsa.GenerateKey(rand.Reader, rsaBits) 197 | case "P224": 198 | return ecdsa.GenerateKey(elliptic.P224(), rand.Reader) 199 | case "P256": 200 | return ecdsa.GenerateKey(elliptic.P256(), rand.Reader) 201 | case "P384": 202 | return ecdsa.GenerateKey(elliptic.P384(), rand.Reader) 203 | case "P521": 204 | return ecdsa.GenerateKey(elliptic.P521(), rand.Reader) 205 | default: 206 | return nil, fmt.Errorf("Unrecognized elliptic curve: %q", ecdsaCurve) 207 | } 208 | } 209 | 210 | func pemBlockForKey(priv interface{}) *pem.Block { 211 | switch k := priv.(type) { 212 | case *rsa.PrivateKey: 213 | return &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(k)} 214 | case *ecdsa.PrivateKey: 215 | b, err := x509.MarshalECPrivateKey(k) 216 | if err != nil { 217 | fmt.Fprintf(os.Stderr, "Unable to marshal ECDSA private key: %v", err) 218 | os.Exit(2) 219 | } 220 | return &pem.Block{Type: "EC PRIVATE KEY", Bytes: b} 221 | default: 222 | return nil 223 | } 224 | } 225 | 226 | var oidEmailAddress = asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 9, 1} 227 | 228 | func generateCsr(id string, priv interface{}) ([]byte, error) { 229 | emailAddress := id + "@arduino.cc" 230 | subj := pkix.Name{ 231 | CommonName: id, 232 | Country: []string{"IT"}, 233 | Province: []string{"Piemonte"}, 234 | Locality: []string{"Torino"}, 235 | Organization: []string{"Arduino AG"}, 236 | OrganizationalUnit: []string{"Cloud"}, 237 | } 238 | rawSubj := subj.ToRDNSequence() 239 | rawSubj = append(rawSubj, []pkix.AttributeTypeAndValue{ 240 | {Type: oidEmailAddress, Value: emailAddress}, 241 | }) 242 | asn1Subj, err := asn1.Marshal(rawSubj) 243 | if err != nil { 244 | return nil, err 245 | } 246 | template := x509.CertificateRequest{ 247 | RawSubject: asn1Subj, 248 | EmailAddresses: []string{emailAddress}, 249 | SignatureAlgorithm: x509.ECDSAWithSHA256, 250 | } 251 | csr, err := x509.CreateCertificateRequest(rand.Reader, &template, priv) 252 | if err != nil { 253 | return nil, err 254 | } 255 | return csr, nil 256 | } 257 | 258 | func formatCSR(csr []byte) string { 259 | pemData := bytes.NewBuffer([]byte{}) 260 | err := pem.Encode(pemData, &pem.Block{Type: "CERTIFICATE REQUEST", Bytes: csr}) 261 | if err != nil { 262 | fmt.Println(err) 263 | return "" 264 | } 265 | 266 | return pemData.String() 267 | } 268 | 269 | func requestCert(apiURL, id, token string, csr []byte) (string, error) { 270 | client := http.Client{ 271 | Timeout: 30 * time.Second, 272 | } 273 | formattedCSR := formatCSR(csr) 274 | payload := `{"csr":"` + formattedCSR + `"}` 275 | payload = strings.Replace(payload, "\n", "\\n", -1) 276 | 277 | req, err := http.NewRequest("POST", apiURL+"/iot/v1/devices/"+id, strings.NewReader(payload)) 278 | if err != nil { 279 | return "", err 280 | } 281 | 282 | req.Header.Add("Authorization", "Bearer "+token) 283 | 284 | res, err := client.Do(req) 285 | if err != nil { 286 | return "", err 287 | } 288 | 289 | if res.StatusCode != 200 { 290 | return "", errors.New("POST " + apiURL + "/iot/v1/devices/" + id + ": expected 200 OK, got " + res.Status) 291 | } 292 | 293 | body, err := ioutil.ReadAll(res.Body) 294 | if err != nil { 295 | return "", err 296 | } 297 | 298 | data := struct { 299 | Certificate string `json:"certificate"` 300 | }{} 301 | 302 | err = json.Unmarshal(body, &data) 303 | if err != nil { 304 | fmt.Println(err) 305 | return "", err 306 | } 307 | 308 | return data.Certificate, nil 309 | } 310 | 311 | func requestURL(apiURL, token string) (string, error) { 312 | client := http.Client{ 313 | Timeout: 30 * time.Second, 314 | } 315 | 316 | req, err := http.NewRequest("POST", apiURL+"/iot/v1/devices/connect", nil) 317 | if err != nil { 318 | return "", err 319 | } 320 | 321 | req.Header.Add("Authorization", "Bearer "+token) 322 | 323 | res, err := client.Do(req) 324 | if err != nil { 325 | return "", err 326 | } 327 | 328 | body, err := ioutil.ReadAll(res.Body) 329 | if err != nil { 330 | return "", err 331 | } 332 | 333 | data := struct { 334 | URL string `json:"url"` 335 | }{} 336 | 337 | err = json.Unmarshal(body, &data) 338 | if err != nil { 339 | return "", errors.Wrap(err, "unmarshal "+string(body)) 340 | } 341 | 342 | return data.URL, nil 343 | } 344 | 345 | type program struct { 346 | Config Config 347 | listenFile string 348 | } 349 | 350 | // Start run the program asynchronously 351 | func (p *program) Start(s service.Service) error { 352 | go p.run() 353 | return nil 354 | } 355 | 356 | // Start run the program asynchronously 357 | func (p *program) Stop(s service.Service) error { 358 | return nil 359 | } 360 | 361 | // createService returns the servcie to be installed 362 | func createService(config Config, configFile, listenFile string) (service.Service, error) { 363 | workingDirectory, _ := osext.ExecutableFolder() 364 | 365 | svcConfig := &service.Config{ 366 | Name: "ArduinoConnector", 367 | DisplayName: "Arduino Connector Service", 368 | Description: "Cloud connector and launcher for IoT devices.", 369 | Arguments: []string{"-config", configFile}, 370 | WorkingDirectory: workingDirectory, 371 | Dependencies: []string{"network-online.target"}, 372 | } 373 | 374 | prg := &program{config, listenFile} 375 | s, err := service.New(prg, svcConfig) 376 | if err != nil { 377 | return nil, err 378 | } 379 | 380 | return s, nil 381 | } 382 | 383 | // registerDevice publishes on the topic /register with info about the device itself 384 | func registerDevice(client mqtt.Client, id string) error { 385 | // get host 386 | host, err := os.Hostname() 387 | if err != nil { 388 | return err 389 | } 390 | 391 | // get Macs 392 | macs, err := getMACs() 393 | if err != nil { 394 | fmt.Println(err) 395 | return err 396 | } 397 | 398 | data := struct { 399 | Host string 400 | MACs []string 401 | }{ 402 | Host: host, 403 | MACs: macs, 404 | } 405 | msg, err := json.Marshal(data) 406 | if err != nil { 407 | return err 408 | } 409 | 410 | if token := client.Publish("$aws/things/"+id+"/register", 1, false, msg); token.Wait() && token.Error() != nil { 411 | return err 412 | } 413 | 414 | return nil 415 | } 416 | 417 | // getMACs returns a list of MAC addresses found on the device 418 | func getMACs() ([]string, error) { 419 | var macAddresses []string 420 | interfaces, err := net.Interfaces() 421 | if err != nil { 422 | return nil, errors.Wrap(err, "get net interfaces") 423 | } 424 | for _, netInterface := range interfaces { 425 | macAddress := netInterface.HardwareAddr 426 | hwAddr, err := net.ParseMAC(macAddress.String()) 427 | if err != nil { 428 | continue 429 | } 430 | macAddresses = append(macAddresses, hwAddr.String()) 431 | } 432 | return macAddresses, nil 433 | } 434 | -------------------------------------------------------------------------------- /install_test.go: -------------------------------------------------------------------------------- 1 | // +build register 2 | 3 | package main 4 | 5 | import ( 6 | "bytes" 7 | "crypto/rand" 8 | "crypto/tls" 9 | "crypto/x509" 10 | "crypto/x509/pkix" 11 | "encoding/asn1" 12 | "encoding/json" 13 | "encoding/pem" 14 | "fmt" 15 | "io/ioutil" 16 | "math/big" 17 | "net/http" 18 | "net/http/httptest" 19 | "os" 20 | "os/exec" 21 | "path/filepath" 22 | "strings" 23 | "sync" 24 | "testing" 25 | "time" 26 | 27 | "github.com/arduino/arduino-connector/auth" 28 | mqtt "github.com/eclipse/paho.mqtt.golang" 29 | "github.com/pkg/errors" 30 | "github.com/stretchr/testify/assert" 31 | ) 32 | 33 | const ( 34 | ID = "arduino-connector-test" 35 | AuthClientID = "test-1234567890" 36 | DeviceCode = "device-1234567890" 37 | AccessToken = "test-token" 38 | CertPath = "test-certs" 39 | URL = "localhost" 40 | ) 41 | 42 | func TestInstallCheckCreateConfig(t *testing.T) { 43 | err := createConfigFolder() 44 | assert.True(t, err == nil) 45 | defer func() { 46 | os.RemoveAll("/etc/arduino-connector") 47 | }() 48 | _, err = os.Stat("/etc/arduino-connector") 49 | assert.True(t, err == nil) 50 | } 51 | 52 | func TestInstallRegister(t *testing.T) { 53 | 54 | // mock the OAuth server 55 | oauthTestServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 56 | if r.URL.Path == "/oauth/device/code" { 57 | assert.Equal(t, http.MethodPost, r.Method) 58 | 59 | assert.Equal(t, "application/x-www-form-urlencoded", r.Header.Get("content-type")) 60 | 61 | body, err := ioutil.ReadAll(r.Body) 62 | assert.NoError(t, err) 63 | assert.Equal(t, "client_id="+AuthClientID+"&audience=https://api.arduino.cc", string(body)) 64 | 65 | dc := auth.DeviceCode{ 66 | DeviceCode: DeviceCode, 67 | UserCode: "", 68 | VerificationURI: "", 69 | ExpiresIn: 0, 70 | Interval: 0, 71 | VerificationURIComplete: "http://test-verification-uri.com", 72 | } 73 | data, err := json.Marshal(dc) 74 | assert.NoError(t, err) 75 | 76 | w.Write(data) 77 | } else if r.URL.Path == "/oauth/token" { 78 | assert.Equal(t, http.MethodPost, r.Method) 79 | 80 | assert.Equal(t, "application/x-www-form-urlencoded", r.Header.Get("content-type")) 81 | 82 | body, err := ioutil.ReadAll(r.Body) 83 | assert.NoError(t, err) 84 | assert.Equal( 85 | t, 86 | "grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code&device_code="+DeviceCode+"&client_id="+AuthClientID, 87 | string(body), 88 | ) 89 | 90 | token := struct { 91 | AccessToken string `json:"access_token"` 92 | ExpiresIn int `json:"expires_in"` 93 | TokenType string `json:"token_type"` 94 | }{ 95 | AccessToken: AccessToken, 96 | ExpiresIn: 0, 97 | TokenType: "", 98 | } 99 | data, err := json.Marshal(token) 100 | assert.NoError(t, err) 101 | 102 | w.Write(data) 103 | } else { 104 | t.Fatalf("unexpected path for oauth test server: %s", r.URL.Path) 105 | } 106 | })) 107 | defer oauthTestServer.Close() 108 | 109 | // mock the AWS API server 110 | awsApiTestServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 111 | if r.URL.Path == "/iot/v1/devices/connect" { 112 | assert.Equal(t, http.MethodPost, r.Method) 113 | 114 | assert.Equal(t, "Bearer "+AccessToken, r.Header.Get("Authorization")) 115 | 116 | resp := struct { 117 | URL string `json:"url"` 118 | }{ 119 | URL: URL, 120 | } 121 | data, err := json.Marshal(resp) 122 | assert.NoError(t, err) 123 | 124 | w.Write(data) 125 | } else if strings.HasPrefix(r.URL.Path, "/iot/v1/devices/") { 126 | assert.Equal(t, "/iot/v1/devices/"+ID, r.URL.Path) 127 | 128 | assert.Equal(t, http.MethodPost, r.Method) 129 | 130 | assert.Equal(t, "Bearer "+AccessToken, r.Header.Get("Authorization")) 131 | 132 | body, err := ioutil.ReadAll(r.Body) 133 | assert.NoError(t, err) 134 | 135 | payload := string(body) 136 | payload = strings.Replace(payload, "\\n", "\n", -1) 137 | 138 | csr := []byte(strings.TrimSuffix(strings.TrimPrefix(payload, `{"csr":"`), `"}`)) 139 | 140 | pemBlock, _ := pem.Decode(csr) 141 | assert.NotNil(t, pemBlock) 142 | 143 | clientCsr, err := x509.ParseCertificateRequest(pemBlock.Bytes) 144 | assert.NoError(t, err) 145 | 146 | assert.NoError(t, clientCsr.CheckSignature()) 147 | 148 | checkCsrRawSubject(t, clientCsr) 149 | 150 | clientCrt, err := crsToCrt( 151 | filepath.Join(CertPath, "test-ca.crt"), 152 | filepath.Join(CertPath, "test-ca.key"), 153 | clientCsr, 154 | ) 155 | assert.NoError(t, err, "error while generating client certificate from certificate signing request") 156 | assert.NotNil(t, clientCrt, "generate client certificate is empty") 157 | 158 | resp := struct { 159 | Certificate string `json:"certificate"` 160 | }{ 161 | Certificate: string(clientCrt), 162 | } 163 | data, err := json.Marshal(resp) 164 | assert.NoError(t, err) 165 | 166 | w.Write(data) 167 | } else { 168 | t.Fatalf("unexpected path for aws api test server: %s", r.URL.Path) 169 | } 170 | })) 171 | defer awsApiTestServer.Close() 172 | 173 | // instantiate an observer client to read messages published by arduino-connector 174 | client, err := connectTestClient( 175 | filepath.Join(CertPath, "test-client.crt"), 176 | filepath.Join(CertPath, "test-client.key"), 177 | ) 178 | assert.NoError(t, err) 179 | 180 | // subscribe to register topic and wait for a message 181 | var wg sync.WaitGroup 182 | wg.Add(1) 183 | client.Subscribe("$aws/things/"+ID+"/register", 1, func(client mqtt.Client, msg mqtt.Message) { 184 | defer wg.Done() 185 | 186 | var data struct { 187 | Host string 188 | MACs []string 189 | } 190 | 191 | err := json.Unmarshal(msg.Payload(), &data) 192 | assert.NoError(t, err) 193 | 194 | host, err := os.Hostname() 195 | assert.NoError(t, err) 196 | 197 | macs, err := getMACs() 198 | assert.NoError(t, err) 199 | 200 | assert.Equal(t, host, data.Host) 201 | assert.Equal(t, macs, data.MACs) 202 | }) 203 | 204 | defer client.Disconnect(100) 205 | 206 | // call register function to test it 207 | testConfig := Config{ 208 | ID: ID, 209 | AuthURL: oauthTestServer.URL, 210 | AuthClientID: AuthClientID, 211 | APIURL: awsApiTestServer.URL, 212 | CertPath: CertPath, 213 | } 214 | 215 | register(testConfig, "config.test", "") 216 | 217 | // check generated config 218 | buf, err := ioutil.ReadFile("config.test") 219 | assert.NoError(t, err) 220 | 221 | config := strings.Replace(string(buf), "\r\n", "\n", -1) 222 | expectedConfig := fmt.Sprintf(`id=%s 223 | url=%s 224 | http_proxy= 225 | https_proxy= 226 | all_proxy= 227 | authurl=%s 228 | auth_client_id=%s 229 | apiurl=%s 230 | cert_path=%s 231 | sketches_path= 232 | check_ro_fs=false 233 | env_vars_to_load= 234 | `, ID, URL, oauthTestServer.URL, AuthClientID, awsApiTestServer.URL, CertPath) 235 | 236 | assert.Equal(t, expectedConfig, config) 237 | 238 | // wait for mqtt test client callback to verify device register info 239 | wg.Wait() 240 | } 241 | 242 | func checkCsrRawSubject(t *testing.T, csr *x509.CertificateRequest) { 243 | var rdnSeq pkix.RDNSequence 244 | rest, err := asn1.Unmarshal(csr.RawSubject, &rdnSeq) 245 | assert.NoError(t, err) 246 | assert.Len(t, rest, 0) 247 | 248 | var subj pkix.Name 249 | subj.FillFromRDNSequence(&rdnSeq) 250 | 251 | assert.Len(t, subj.Country, 1) 252 | assert.Equal(t, "IT", subj.Country[0]) 253 | 254 | assert.Len(t, subj.Organization, 1) 255 | assert.Equal(t, "Arduino AG", subj.Organization[0]) 256 | 257 | assert.Len(t, subj.OrganizationalUnit, 1) 258 | assert.Equal(t, "Cloud", subj.OrganizationalUnit[0]) 259 | 260 | assert.Len(t, subj.Province, 1) 261 | assert.Equal(t, "Piemonte", subj.Province[0]) 262 | 263 | assert.Len(t, subj.Locality, 1) 264 | assert.Equal(t, "Torino", subj.Locality[0]) 265 | 266 | assert.Equal(t, ID, subj.CommonName) 267 | 268 | objIds := make(map[string]string) 269 | for _, oid := range subj.Names { 270 | objIds[oid.Type.String()] = oid.Value.(string) 271 | } 272 | oidEmailKey := asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 9, 1}.String() 273 | assert.Contains(t, objIds, oidEmailKey) 274 | assert.Equal(t, ID+"@arduino.cc", objIds[oidEmailKey]) 275 | } 276 | 277 | func crsToCrt(caCrtPath, caKeyPath string, csr *x509.CertificateRequest) ([]byte, error) { 278 | // load CA certificate and key 279 | caCrtFile, err := ioutil.ReadFile(caCrtPath) 280 | if err != nil { 281 | return nil, err 282 | } 283 | pemBlock, _ := pem.Decode(caCrtFile) 284 | if pemBlock == nil { 285 | return nil, err 286 | } 287 | caCrt, err := x509.ParseCertificate(pemBlock.Bytes) 288 | if err != nil { 289 | return nil, err 290 | } 291 | 292 | caKeyFile, err := ioutil.ReadFile(caKeyPath) 293 | if err != nil { 294 | return nil, err 295 | } 296 | pemBlock, _ = pem.Decode(caKeyFile) 297 | if pemBlock == nil { 298 | return nil, err 299 | } 300 | 301 | caPrivateKey, err := x509.ParsePKCS8PrivateKey(pemBlock.Bytes) 302 | if err != nil { 303 | return nil, err 304 | } 305 | 306 | // create client certificate template 307 | clientCrtTemplate := x509.Certificate{ 308 | Signature: csr.Signature, 309 | SignatureAlgorithm: csr.SignatureAlgorithm, 310 | 311 | PublicKeyAlgorithm: csr.PublicKeyAlgorithm, 312 | PublicKey: csr.PublicKey, 313 | 314 | SerialNumber: big.NewInt(2), 315 | Issuer: caCrt.Subject, 316 | Subject: csr.Subject, 317 | NotBefore: time.Now(), 318 | NotAfter: time.Now().Add(24 * time.Hour), 319 | KeyUsage: x509.KeyUsageDigitalSignature, 320 | ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, 321 | } 322 | 323 | // create client certificate from template and CA public key 324 | clientCRTRaw, err := x509.CreateCertificate( 325 | rand.Reader, 326 | &clientCrtTemplate, 327 | caCrt, 328 | csr.PublicKey, 329 | caPrivateKey, 330 | ) 331 | if err != nil { 332 | return nil, err 333 | } 334 | 335 | var pemCrt bytes.Buffer 336 | pem.Encode(&pemCrt, &pem.Block{Type: "CERTIFICATE", Bytes: clientCRTRaw}) 337 | 338 | return pemCrt.Bytes(), nil 339 | } 340 | 341 | func connectTestClient(crtPath, keyPath string) (mqtt.Client, error) { 342 | // Read client certificate 343 | cer, err := tls.LoadX509KeyPair(crtPath, keyPath) 344 | if err != nil { 345 | return nil, errors.Wrap(err, "test-client read certificate") 346 | } 347 | 348 | opts := mqtt.NewClientOptions() 349 | opts.SetClientID("test-client") 350 | opts.SetMaxReconnectInterval(20 * time.Second) 351 | opts.SetConnectTimeout(30 * time.Second) 352 | opts.SetAutoReconnect(true) 353 | opts.SetTLSConfig(&tls.Config{ 354 | Certificates: []tls.Certificate{cer}, 355 | ServerName: "localhost", 356 | }) 357 | opts.AddBroker("tcps://localhost:8883/mqtt") 358 | 359 | mqttClient := mqtt.NewClient(opts) 360 | if token := mqttClient.Connect(); token.Wait() && token.Error() != nil { 361 | return nil, errors.Wrap(token.Error(), "test-client connection to broker") 362 | } 363 | 364 | return mqttClient, nil 365 | } 366 | 367 | func isDockerInstalled() (bool, error) { 368 | _, err := exec.LookPath("docker") 369 | if err == nil { 370 | return true, nil 371 | } 372 | 373 | return false, nil 374 | } 375 | 376 | func TestInstallDocker(t *testing.T) { 377 | checkAndInstallDocker() 378 | installed, err := isDockerInstalled() 379 | assert.True(t, err == nil) 380 | assert.True(t, installed) 381 | } 382 | 383 | func isNetManagerInstalled() bool { 384 | cmd := exec.Command("bash", "-c", "dpkg --get-selections | grep network-manager") 385 | out, _ := cmd.CombinedOutput() 386 | return !strings.Contains(string(out), "deinstall") && len(out) != 0 387 | } 388 | 389 | func TestInstallNetworkManager(t *testing.T) { 390 | assert.False(t, isNetManagerInstalled()) 391 | checkAndInstallNetworkManager() 392 | defer func() { 393 | c := exec.Command("bash", "-c", "apt-get remove -y network-manager") 394 | _, err := c.CombinedOutput() 395 | assert.True(t, err == nil) 396 | 397 | assert.False(t, isNetManagerInstalled()) 398 | }() 399 | assert.True(t, isNetManagerInstalled()) 400 | } 401 | -------------------------------------------------------------------------------- /scripts/README.md: -------------------------------------------------------------------------------- 1 | 2 | ## Generate temporary installer script 3 | ``` 4 | aws-google-auth -p arduino 5 | go build -ldflags "-X main.version=2.0.21" github.com/arduino/arduino-connector 6 | aws --profile arduino s3 cp arduino-connector-dev.sh s3://arduino-tmp/arduino-connector.sh 7 | aws s3 presign --profile arduino s3://arduino-tmp/arduino-connector.sh --expires-in $(expr 3600 \* 72) 8 | #use this link i the wget of the getting started script 9 | aws --profile arduino s3 cp arduino-connector s3://arduino-tmp/ 10 | aws s3 presign --profile arduino s3://arduino-tmp/arduino-connector --expires-in $(expr 3600 \* 72) 11 | # use the output as the argument of arduino-connector-dev.sh qhen launching getting started script: 12 | 13 | export id=containtel:a4ae70c4-b7ff-40c8-83c1-1e10ee166241 14 | wget -O install.sh 15 | chmod +x install.sh 16 | ./install.sh 17 | 18 | ``` 19 | 20 | i.e 21 | ``` 22 | export id=containtel:a4ae70c4-b7ff-40c8-83c1-1e10ee166241 23 | wget -O install.sh "https://arduino-tmp.s3.amazonaws.com/arduino-connector.sh?AWSAccessKeyId=ASIAJJFZDTIGHJCWMGQA&Expires=1529771794&x-amz-security-token=FQoDYXdzEBoaDD8duZwY18MeYFd3CyLPAjxH7ijRrTBwduS9r8Dqm06%2BT%2B6p57cOU4I1Bn3d09lMVjPi4dhNQboAxLnYSI%2BNqxUo%2BbgNDxRbIVxzgvGWQHw7Seepjniy%2FvCKpR7DuxyNe%2B5DxA15O1fGZDQkqadxlky5jkXk1Vn9TBtGa4NCRMgIoatRBtkHI7XKpouWNYhh2jYo7ezeDRQO3m1WR7WieqVlh%2BdscL0NevGGMOh3MYf5Wsm069GuA31FmTslp3SaChf7Mq7uOI5X9XIu%2B9kcWnxXoo7dMCk5Ixq5WLkB%2BUlTt6iL4bxK7FKdlT%2FUsf5DSfBcCGwcyI2nBuFB6yjPeS5AAm0ZUU6DaEd9KUc8Fxq9M1tEQ3DnjGnKZcbaOU%2FGWw7bnOPhLcl6eiNIOtZxsvZ4MCTY3YUnO4rna4fVNScjIqMwNdb8psFarGH1Gn0e4DRNt22LFshjGZdNi01RKI%2BFqtkF&Signature=jI00Smxp33Y72ijdRJsXMIYx9h0%3D" 24 | chmod +x install.sh 25 | ./install.sh "https://arduino-tmp.s3.amazonaws.com/arduino-connector?AWSAccessKeyId=ASIAJJFZDTIGHJCWMGQA&Expires=1529771799&x-amz-security-token=FQoDYXdzEBoaDD8duZwY18MeYFd3CyLPAjxH7ijRrTBwduS9r8Dqm06%2BT%2B6p57cOU4I1Bn3d09lMVjPi4dhNQboAxLnYSI%2BNqxUo%2BbgNDxRbIVxzgvGWQHw7Seepjniy%2FvCKpR7DuxyNe%2B5DxA15O1fGZDQkqadxlky5jkXk1Vn9TBtGa4NCRMgIoatRBtkHI7XKpouWNYhh2jYo7ezeDRQO3m1WR7WieqVlh%2BdscL0NevGGMOh3MYf5Wsm069GuA31FmTslp3SaChf7Mq7uOI5X9XIu%2B9kcWnxXoo7dMCk5Ixq5WLkB%2BUlTt6iL4bxK7FKdlT%2FUsf5DSfBcCGwcyI2nBuFB6yjPeS5AAm0ZUU6DaEd9KUc8Fxq9M1tEQ3DnjGnKZcbaOU%2FGWw7bnOPhLcl6eiNIOtZxsvZ4MCTY3YUnO4rna4fVNScjIqMwNdb8psFarGH1Gn0e4DRNt22LFshjGZdNi01RKI%2BFqtkF&Signature=BTsZzRhHnf%2Fl%2BWsXfJ9MB1ir318%3D" 26 | 27 | ``` -------------------------------------------------------------------------------- /scripts/arduino-connector-dev.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | # 4 | # This file is part of arduino-connector 5 | # 6 | # Copyright (C) 2017-2018 Arduino AG (http://www.arduino.cc/) 7 | # 8 | # Licensed under the Apache License, Version 2.0 (the "License"); 9 | # you may not use this file except in compliance with the License. 10 | # You may obtain a copy of the License at 11 | # 12 | # http://www.apache.org/licenses/LICENSE-2.0 13 | # 14 | # Unless required by applicable law or agreed to in writing, software 15 | # distributed under the License is distributed on an "AS IS" BASIS, 16 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 17 | # See the License for the specific language governing permissions and 18 | # limitations under the License. 19 | # 20 | 21 | has() { 22 | type "$1" > /dev/null 2>&1 23 | return $? 24 | } 25 | 26 | download() { 27 | if has "wget"; then 28 | wget -O arduino-connector -nc ${1} 29 | else 30 | echo "Error: you need wget to proceed" >&2; 31 | exit 20 32 | fi 33 | } 34 | 35 | # Replicate env variables in uppercase format 36 | export ID=$id 37 | export TOKEN=$token 38 | export HTTP_PROXY=$http_proxy 39 | export HTTPS_PROXY=$https_proxy 40 | export ALL_PROXY=$all_proxy 41 | 42 | echo printenv 43 | echo --------- 44 | 45 | cd $HOME 46 | echo home folder 47 | echo --------- 48 | 49 | echo remove old files 50 | echo --------- 51 | sudo rm -f /usr/bin/arduino-connector* 52 | sudo rm -rf /etc/arduino-connector 53 | 54 | echo uninstall previous installations of connector 55 | echo --------- 56 | if [ "$password" == "" ] 57 | then 58 | sudo systemctl stop ArduinoConnector || true 59 | else 60 | echo $password | sudo -kS systemctl stop ArduinoConnector || true 61 | fi 62 | 63 | if [ "$password" == "" ] 64 | then 65 | sudo rm -f /etc/systemd/system/ArduinoConnector.service 66 | else 67 | echo $password | sudo -kS rm -f /etc/systemd/system/ArduinoConnector.service 68 | fi 69 | 70 | echo download connector 71 | echo --------- 72 | download $1 73 | chmod +x arduino-connector 74 | 75 | echo install connector 76 | echo --------- 77 | if [ "$password" == "" ] 78 | then 79 | sudo -E ./arduino-connector -register -install 80 | else 81 | echo $password | sudo -kS -E ./arduino-connector -register -install > arduino-connector.log 2>&1 82 | fi 83 | 84 | if [ "$password" == "" ] 85 | then 86 | sudo chown $USER arduino-connector.cfg 87 | else 88 | echo $password | sudo -kS chown $USER arduino-connector.cfg 89 | fi 90 | 91 | 92 | echo start connector service 93 | echo --------- 94 | if [ "$password" == "" ] 95 | then 96 | sudo systemctl start ArduinoConnector 97 | else 98 | echo $password | sudo -kS systemctl start ArduinoConnector 99 | fi 100 | -------------------------------------------------------------------------------- /scripts/install-tests.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -euo pipefail 4 | 5 | trap 'kill "$(pidof mosquitto)"; rm -rf mosquitto.conf test-certs/ 2> /dev/null' EXIT 6 | 7 | mkdir -p test-certs 8 | 9 | # create a test Certification Authority 10 | openssl req \ 11 | -new \ 12 | -newkey ec:<(openssl ecparam -name prime256v1) \ 13 | -days 7 \ 14 | -nodes \ 15 | -x509 \ 16 | -subj "/C=IT/ST=Piemonte/L=Torino/O=TestCA/CN=CA" \ 17 | -keyout test-certs/test-ca.key \ 18 | -out test-certs/test-ca.crt 19 | 20 | # install the CA 21 | chmod 0644 test-certs/test-ca.crt 22 | cp test-certs/test-ca.crt /usr/local/share/ca-certificates/ 23 | update-ca-certificates 24 | 25 | # create server certificate 26 | openssl req \ 27 | -new \ 28 | -newkey ec:<(openssl ecparam -name prime256v1) \ 29 | -nodes \ 30 | -keyout test-certs/test-server.key \ 31 | -out test-certs/test-server.csr \ 32 | -subj "/C=IT/ST=Piemonte/L=Torino/O=TestServer/CN=localhost" 33 | 34 | openssl x509 \ 35 | -req \ 36 | -in test-certs/test-server.csr \ 37 | -CA test-certs/test-ca.crt \ 38 | -CAkey test-certs/test-ca.key \ 39 | -CAcreateserial \ 40 | -out test-certs/test-server.crt \ 41 | -days 7 \ 42 | -sha256 43 | 44 | # create client certificate 45 | openssl req \ 46 | -new \ 47 | -newkey ec:<(openssl ecparam -name prime256v1) \ 48 | -nodes \ 49 | -keyout test-certs/test-client.key \ 50 | -out test-certs/test-client.csr \ 51 | -subj "/C=IT/ST=Piemonte/L=Torino/O=TestServer/CN=localhost" 52 | 53 | openssl x509 \ 54 | -req \ 55 | -in test-certs/test-client.csr \ 56 | -CA test-certs/test-ca.crt \ 57 | -CAkey test-certs/test-ca.key \ 58 | -CAcreateserial \ 59 | -out test-certs/test-client.crt \ 60 | -days 7 \ 61 | -sha256 62 | 63 | # generate mosquitto config to use client certificates 64 | echo \ 65 | "port 8883 66 | require_certificate true 67 | cafile ./test-certs/test-ca.crt 68 | keyfile ./test-certs/test-server.key 69 | certfile ./test-certs/test-server.crt 70 | log_dest none" \ 71 | > mosquitto.conf 72 | 73 | mosquitto -c mosquitto.conf > /dev/null & 74 | 75 | # see https://github.com/golang/go/issues/39568 about why we need to set that GODEBUG value 76 | GODEBUG=x509ignoreCN=0 go test -v --tags=register --run="TestInstall" --timeout=100s -------------------------------------------------------------------------------- /scripts/mqtt-tests.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -euo pipefail 4 | 5 | trap 'kill "$(pidof mosquitto)"' EXIT 6 | 7 | mosquitto > /dev/null & 8 | go test -v --tags=functional --run=TestDocker 9 | go test -v --run=TestApt -------------------------------------------------------------------------------- /status.go: -------------------------------------------------------------------------------- 1 | // 2 | // This file is part of arduino-connector 3 | // 4 | // Copyright (C) 2017-2020 Arduino AG (http://www.arduino.cc/) 5 | // 6 | // Licensed under the Apache License, Version 2.0 (the "License"); 7 | // you may not use this file except in compliance with the License. 8 | // You may obtain a copy of the License at 9 | // 10 | // http://www.apache.org/licenses/LICENSE-2.0 11 | // 12 | // Unless required by applicable law or agreed to in writing, software 13 | // distributed under the License is distributed on an "AS IS" BASIS, 14 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | // See the License for the specific language governing permissions and 16 | // limitations under the License. 17 | // 18 | 19 | package main 20 | 21 | import ( 22 | "encoding/json" 23 | "fmt" 24 | "os" 25 | "strconv" 26 | "time" 27 | 28 | docker "github.com/docker/docker/client" 29 | mqtt "github.com/eclipse/paho.mqtt.golang" 30 | "github.com/pkg/errors" 31 | ) 32 | 33 | // Status contains info about the sketches running on the device 34 | type Status struct { 35 | config Config 36 | id string 37 | mqttClient mqtt.Client 38 | dockerClient docker.APIClient 39 | Sketches map[string]*SketchStatus `json:"sketches"` 40 | messagesSent int 41 | firstMessageAt time.Time 42 | topicPertinence string 43 | } 44 | 45 | // SketchBinding represents a pair (SketchName,SketchId) 46 | type SketchBinding struct { 47 | Name string `json:"name"` 48 | ID string `json:"id"` 49 | } 50 | 51 | // SketchStatus contains info about a single running sketch 52 | type SketchStatus struct { 53 | Name string `json:"name"` 54 | ID string `json:"id"` 55 | PID int `json:"pid"` 56 | Status string `json:"status"` // could be bool if we don't allow Pause 57 | Endpoints []Endpoint `json:"endpoints"` 58 | pty *os.File 59 | } 60 | 61 | // Endpoint is an exposed function 62 | type Endpoint struct { 63 | Name string `json:"name"` 64 | Arguments string `json:"arguments"` 65 | } 66 | 67 | // NewStatus creates a new status that publishes on a topic 68 | func NewStatus(config Config, mqttClient mqtt.Client, dockerClient docker.APIClient, topicPertinence string) *Status { 69 | return &Status{ 70 | config: config, 71 | id: config.ID, 72 | mqttClient: mqttClient, 73 | dockerClient: dockerClient, 74 | Sketches: map[string]*SketchStatus{}, 75 | topicPertinence: topicPertinence, 76 | } 77 | } 78 | 79 | // Set adds or modify a sketch 80 | func (s *Status) Set(name string, sketch *SketchStatus) { 81 | s.Sketches[name] = sketch 82 | 83 | if s.mqttClient == nil { 84 | return 85 | } 86 | msg, err := json.Marshal(s) 87 | if err != nil { 88 | panic(err) // Means that something went really wrong 89 | } 90 | 91 | s.messagesSent++ 92 | if token := s.mqttClient.Publish("/status", 1, false, msg); token.Wait() && token.Error() != nil { 93 | panic(err) // Means that something went really wrong 94 | } 95 | if debugMqtt { 96 | fmt.Println("MQTT OUT: /status", string(msg)) 97 | } 98 | } 99 | 100 | // Error logs an error on the specified topic 101 | func (s *Status) Error(topic string, err error) { 102 | if s.mqttClient == nil { 103 | return 104 | } 105 | s.messagesSent++ 106 | token := s.mqttClient.Publish(s.topicPertinence+topic, 1, false, "ERROR: "+err.Error()+"\n") 107 | token.Wait() 108 | if debugMqtt { 109 | fmt.Println("MQTT OUT: "+s.topicPertinence+s.id+topic, "ERROR: "+err.Error()+"\n") 110 | } 111 | } 112 | 113 | // Info logs a message on the specified topic 114 | func (s *Status) Info(topic, msg string) bool { 115 | if s.mqttClient == nil { 116 | return false 117 | } 118 | s.messagesSent++ 119 | token := s.mqttClient.Publish("$aws/things/"+s.id+topic, 1, false, "INFO: "+msg+"\n") 120 | res := token.Wait() 121 | if debugMqtt { 122 | fmt.Println("MQTT OUT: $aws/things/"+s.id+topic, "INFO: "+msg+"\n") 123 | } 124 | return res 125 | } 126 | 127 | // SendInfo send information to a specific topic 128 | func (s *Status) SendInfo(topic, msg string) { 129 | if s.mqttClient == nil { 130 | return 131 | } 132 | 133 | s.messagesSent++ 134 | 135 | if token := s.mqttClient.Publish(s.topicPertinence+topic, 0, false, "INFO: "+msg+"\n"); token.Wait() && token.Error() != nil { 136 | s.Error(topic, token.Error()) 137 | } 138 | 139 | if debugMqtt { 140 | fmt.Println("MQTT OUT: "+s.topicPertinence+topic, "INFO: "+msg+"\n") 141 | } 142 | } 143 | 144 | // Raw sends a message on the specified topic without further processing 145 | func (s *Status) Raw(topic, msg string) { 146 | if s.mqttClient == nil { 147 | return 148 | } 149 | 150 | if s.messagesSent < 10 { 151 | // first 10 messages are virtually free 152 | s.firstMessageAt = time.Now() 153 | } 154 | 155 | if s.messagesSent > 1000 { 156 | // if started more than one day ago, reset the counter 157 | if time.Since(s.firstMessageAt) > 24*time.Hour { 158 | s.messagesSent = 0 159 | } 160 | 161 | fmt.Println("rate limiting: " + strconv.Itoa(s.messagesSent)) 162 | introducedDelay := time.Duration(s.messagesSent/1000) * time.Second 163 | if introducedDelay > 20*time.Second { 164 | introducedDelay = 20 * time.Second 165 | } 166 | time.Sleep(introducedDelay) 167 | } 168 | s.messagesSent++ 169 | token := s.mqttClient.Publish("$aws/things/"+s.id+topic, 1, false, msg) 170 | token.Wait() 171 | if debugMqtt { 172 | fmt.Println("MQTT OUT: $aws/things/"+s.id+topic, string(msg)) 173 | } 174 | } 175 | 176 | // InfoCommandOutput sends command output on the specified topic 177 | func (s *Status) InfoCommandOutput(topic string, out []byte) { 178 | // Prepare response payload 179 | type response struct { 180 | Output string `json:"output"` 181 | } 182 | info := response{Output: string(out)} 183 | data, err := json.Marshal(info) 184 | if err != nil { 185 | s.Error(topic, fmt.Errorf("Json marshal result: %s", err)) 186 | return 187 | } 188 | 189 | // Send result 190 | s.Info(topic, string(data)+"\n") 191 | } 192 | 193 | // Publish sens on the /status topic a json representation of the connector 194 | func (s *Status) Publish() { 195 | data, err := json.Marshal(s) 196 | 197 | //var out bytes.Buffer 198 | //json.Indent(&out, data, "", " ") 199 | //fmt.Println(string(out.Bytes())) 200 | 201 | if err != nil { 202 | s.Error("/status", errors.Wrap(err, "status request")) 203 | return 204 | } 205 | 206 | s.Info("/status", string(data)+"\n") 207 | } 208 | -------------------------------------------------------------------------------- /test/.gitignore: -------------------------------------------------------------------------------- 1 | .vagrant 2 | *.retry 3 | *.log 4 | ui_gen_install.sh 5 | install.sh 6 | *.pem 7 | cert_arn.sh -------------------------------------------------------------------------------- /test/Vagrantfile: -------------------------------------------------------------------------------- 1 | # This guide is optimized for Vagrant 1.7 and above. 2 | # Although versions 1.6.x should behave very similarly, it is recommended 3 | # to upgrade instead of disabling the requirement below. 4 | Vagrant.require_version ">= 2.0.0" 5 | 6 | # Check for missing plugins 7 | # required_plugins = %w[vagrant-triggers] 8 | 9 | # return if !Vagrant.plugins_enabled? 10 | 11 | # plugins_to_install = required_plugins.select { |plugin| !Vagrant.has_plugin? plugin } 12 | 13 | # if plugins_to_install.any? 14 | # system "vagrant plugin install #{plugins_to_install.join(' ')}" 15 | # exit system 'vagrant up' 16 | # end 17 | 18 | Vagrant.configure("2") do |config| 19 | 20 | config.vm.box = "ubuntu/xenial64" 21 | 22 | config.vm.provider "virtualbox" do |v| 23 | v.name = "connector_integ_vm" 24 | # try to improve vm net speed 25 | v.customize ["modifyvm", :id, "--nictype1", "virtio"] 26 | end 27 | 28 | # Workaround for installing python 29 | config.vm.provision "shell", inline: "which python || sudo apt -y install python" 30 | 31 | # Disable the new defaulawst behavior introduced in Vagrant 1.7, to 32 | # ensure that all Vagrant machines will use the same SSH key pair. 33 | # See https://github.com/mitchellh/vagrant/issues/5005 34 | config.ssh.insert_key = false 35 | 36 | config.vm.provision "ansible" do |ansible| 37 | ansible.compatibility_mode = "2.0" 38 | ansible.verbose = "vv" 39 | ansible.playbook = "playbook.yml" 40 | end 41 | end -------------------------------------------------------------------------------- /test/create_iot_device.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | 5 | RAW_CERT_ARN=$(aws iot --profile arduino create-keys-and-certificate --set-as-active --certificate-pem-outfile cert.pem --public-key-outfile publicKey.pem --private-key-outfile privateKey.pem --query 'certificateArn') 6 | temp="${RAW_CERT_ARN%\"}" 7 | CERT_ARN="${temp#\"}" 8 | RAW_IOT_ENDPOINT=$(aws iot --profile arduino describe-endpoint --endpoint-type iot:Data --query 'endpointAddress') 9 | temp="${RAW_IOT_ENDPOINT%\"}" 10 | IOT_ENDPOINT="${temp#\"}" 11 | 12 | cat > cert_arn.sh </test-sleeper:latest &&\ 4 | #docker push /test-sleeper:latest 5 | 6 | FROM centos 7 | ADD run.sh /tmp/run.sh 8 | RUN chmod +x /tmp/run.sh 9 | ENTRYPOINT ["/tmp/run.sh"] -------------------------------------------------------------------------------- /test/private_image/run.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | while true; do sleep 15 ; echo "background"; done & 4 | 5 | while true; do sleep 12 ; echo "foreground"; done -------------------------------------------------------------------------------- /test/sketch_devops_integ_test/ReadMe.adoc: -------------------------------------------------------------------------------- 1 | :Author: devops-test 2 | :Email: {AuthorEmail} 3 | :Date: 04/01/2019 4 | :Revision: version# 5 | :License: Public Domain 6 | 7 | = Project: {Project} 8 | 9 | Describe your project 10 | 11 | == Step 1: Installation 12 | Please describe the steps to install this project. 13 | 14 | For example: 15 | 16 | 1. Open this file 17 | 2. Edit as you like 18 | 3. Release to the World! 19 | 20 | == Step 2: Assemble the circuit 21 | 22 | Assemble the circuit following the diagram layout.png attached to the sketch 23 | 24 | == Step 3: Load the code 25 | 26 | Upload the code contained in this sketch on to your board 27 | 28 | === Folder structure 29 | 30 | .... 31 | sketch123 => Arduino sketch folder 32 | ├── sketch123.ino => main Arduino file 33 | ├── schematics.png => (optional) an image of the required schematics 34 | ├── layout.png => (optional) an image of the layout 35 | └── ReadMe.adoc => this file 36 | .... 37 | 38 | === License 39 | This project is released under a {License} License. 40 | 41 | === Contributing 42 | To contribute to this project please contact devops-test https://id.arduino.cc/devops-test 43 | 44 | === BOM 45 | Add the bill of the materials you need for this project. 46 | 47 | |=== 48 | | ID | Part name | Part number | Quantity 49 | | R1 | 10k Resistor | 1234-abcd | 10 50 | | L1 | Red LED | 2345-asdf | 5 51 | | A1 | Arduino Zero | ABX00066 | 1 52 | |=== 53 | 54 | 55 | === Help 56 | This document is written in the _AsciiDoc_ format, a markup language to describe documents. 57 | If you need help you can search the http://www.methods.co.nz/asciidoc[AsciiDoc homepage] 58 | or consult the http://powerman.name/doc/asciidoc[AsciiDoc cheatsheet] 59 | -------------------------------------------------------------------------------- /test/sketch_devops_integ_test/sketch.json: -------------------------------------------------------------------------------- 1 | {"cpu":{"fqbn":"arduino:mraa:intel_x86_64","name":"vagrant-integ-test","com_name":"devops-test:c4d6adc7-a2ca-43ec-9ea6-20568bf407fc","id":"devops-test:c4d6adc7-a2ca-43ec-9ea6-20568bf407fc","status":{"sketches":{"0774e17e-f60e-4562-b87d-18017b6ef3d2":{"name":"sketch_devops_integ_test","id":"0774e17e-f60e-4562-b87d-18017b6ef3d2","pid":17769,"status":"RUNNING","endpoints":null}}}},"secrets":[]} -------------------------------------------------------------------------------- /test/sketch_devops_integ_test/sketch_devops_integ_test.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arduino/arduino-connector/c5aa16ed97d600a12bd06ccac996141a9bd9aa16/test/sketch_devops_integ_test/sketch_devops_integ_test.bin -------------------------------------------------------------------------------- /test/sketch_devops_integ_test/sketch_devops_integ_test.bin.sig: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arduino/arduino-connector/c5aa16ed97d600a12bd06ccac996141a9bd9aa16/test/sketch_devops_integ_test/sketch_devops_integ_test.bin.sig -------------------------------------------------------------------------------- /test/sketch_devops_integ_test/sketch_devops_integ_test.elf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arduino/arduino-connector/c5aa16ed97d600a12bd06ccac996141a9bd9aa16/test/sketch_devops_integ_test/sketch_devops_integ_test.elf -------------------------------------------------------------------------------- /test/sketch_devops_integ_test/sketch_devops_integ_test.elf.sig: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arduino/arduino-connector/c5aa16ed97d600a12bd06ccac996141a9bd9aa16/test/sketch_devops_integ_test/sketch_devops_integ_test.elf.sig -------------------------------------------------------------------------------- /test/sketch_devops_integ_test/sketch_devops_integ_test.ino: -------------------------------------------------------------------------------- 1 | /* */ void setup() { } void loop() { } -------------------------------------------------------------------------------- /test/sketch_devops_integ_test/sketch_devops_integ_test_malicious/sketch_devops_integ_test.elf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arduino/arduino-connector/c5aa16ed97d600a12bd06ccac996141a9bd9aa16/test/sketch_devops_integ_test/sketch_devops_integ_test_malicious/sketch_devops_integ_test.elf -------------------------------------------------------------------------------- /test/sketch_devops_integ_test/sketch_devops_integ_test_malicious/sketch_devops_integ_test.elf.sig: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arduino/arduino-connector/c5aa16ed97d600a12bd06ccac996141a9bd9aa16/test/sketch_devops_integ_test/sketch_devops_integ_test_malicious/sketch_devops_integ_test.elf.sig -------------------------------------------------------------------------------- /test/sketch_env_integ_test/connector_env_var_test.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arduino/arduino-connector/c5aa16ed97d600a12bd06ccac996141a9bd9aa16/test/sketch_env_integ_test/connector_env_var_test.bin -------------------------------------------------------------------------------- /test/sketch_env_integ_test/connector_env_var_test.bin.sig: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arduino/arduino-connector/c5aa16ed97d600a12bd06ccac996141a9bd9aa16/test/sketch_env_integ_test/connector_env_var_test.bin.sig -------------------------------------------------------------------------------- /test/sketch_env_integ_test/connector_env_var_test.ino: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | */ 4 | 5 | void setup() { 6 | System.runShellCommand("printenv > /tmp/printenv.out"); 7 | exit(0); 8 | } 9 | 10 | void loop() { 11 | 12 | } -------------------------------------------------------------------------------- /test/teardown_dev_artifacts.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | rm -f ui_gen_install.sh install.sh setup_host_test_env.sh -------------------------------------------------------------------------------- /test/teardown_iot_device.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | # teardown 4 | source cert_arn.sh 5 | echo ${CERT_ARN} 6 | CERT_ID=${CERT_ARN##*/} 7 | aws iot --profile arduino detach-policy --policy-name "DevicePolicy" --target ${CERT_ARN} 8 | aws iot --profile arduino detach-thing-principal --thing-name "testThingVagrant" --principal ${CERT_ARN} 9 | aws iot --profile arduino delete-thing --thing-name "testThingVagrant" 10 | aws iot --profile arduino update-certificate --certificate-id ${CERT_ID} --new-status INACTIVE 11 | aws iot --profile arduino delete-certificate --certificate-id ${CERT_ID} 12 | echo "cleanup files..." 13 | rm -f cert.pem privateKey.pem publicKey.pem rootCA.pem cert_arn.sh -------------------------------------------------------------------------------- /test/upload_dev_artifacts_on_s3.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | # upload arduino-connector-binary and generate installer 5 | aws --profile arduino s3 cp ../scripts/arduino-connector-dev.sh s3://arduino-tmp/arduino-connector.sh 6 | SHELL_INSTALLER=$(aws s3 presign --profile arduino s3://arduino-tmp/arduino-connector.sh --expires-in $(expr 3600 \* 72)) 7 | #use this link i the wget of the getting started script 8 | aws --profile arduino s3 cp ../arduino-connector s3://arduino-tmp/ 9 | ARDUINO_CONNECTOR=$(aws s3 presign --profile arduino s3://arduino-tmp/arduino-connector --expires-in $(expr 3600 \* 72)) 10 | # use the output as the argument of arduino-connector-dev.sh qhen launching getting started script: 11 | 12 | cat >ui_gen_install.sh <setup_host_test_env.sh < 0 { 246 | log.Println("Prerelease versions:") 247 | for _, pre := range v2.Pre { 248 | if pre.String() == "dev" { 249 | return errors.New("dev version") 250 | } 251 | } 252 | } 253 | if v1.Compare(v2) <= 0 { 254 | return errors.New("already at latest version") 255 | } 256 | bin, err := u.fetchAndVerifyPatch(old) 257 | if err != nil { 258 | if err == errHashMismatch { 259 | log.Println("update: hash mismatch from patched binary") 260 | } else { 261 | if u.DiffURL != "" { 262 | log.Println("update: patching binary,", err) 263 | } 264 | } 265 | 266 | bin, err = u.fetchAndVerifyFullBin() 267 | if err != nil { 268 | if err == errHashMismatch { 269 | log.Println("update: hash mismatch from full binary") 270 | } else { 271 | log.Println("update: fetching full binary,", err) 272 | } 273 | return err 274 | } 275 | } 276 | 277 | // close the old binary before installing because on windows 278 | // it can't be renamed if a handle to the file is still open 279 | old.Close() 280 | 281 | err, errRecover := up.FromStream(bytes.NewBuffer(bin)) 282 | if errRecover != nil { 283 | return fmt.Errorf("update and recovery errors: %q %q", err, errRecover) 284 | } 285 | if err != nil { 286 | return err 287 | } 288 | 289 | return nil 290 | } 291 | -------------------------------------------------------------------------------- /utils.go: -------------------------------------------------------------------------------- 1 | // 2 | // This file is part of arduino-connector 3 | // 4 | // Copyright (C) 2017-2018 Arduino AG (http://www.arduino.cc/) 5 | // 6 | // Licensed under the Apache License, Version 2.0 (the "License"); 7 | // you may not use this file except in compliance with the License. 8 | // You may obtain a copy of the License at 9 | // 10 | // http://www.apache.org/licenses/LICENSE-2.0 11 | // 12 | // Unless required by applicable law or agreed to in writing, software 13 | // distributed under the License is distributed on an "AS IS" BASIS, 14 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | // See the License for the specific language governing permissions and 16 | // limitations under the License. 17 | // 18 | 19 | package main 20 | 21 | import ( 22 | "os" 23 | "path/filepath" 24 | "regexp" 25 | "strings" 26 | ) 27 | 28 | func addIntelLibrariesToLdPath() { 29 | _, err := os.Stat("/opt/intel") 30 | if err == nil { 31 | //scan /opt/intel searching for sdks 32 | var extraPaths []string 33 | err = filepath.Walk("/opt/intel", func(path string, f os.FileInfo, err error) error { 34 | path = strings.ToLower(path) 35 | 36 | regex := regexp.MustCompile(".*system.*studio.*|.*qt.*|.*centos.*") 37 | if strings.Contains(f.Name(), ".so") && !strings.Contains(path, "uninstall") && !regex.MatchString(path) { 38 | extraPaths = appendIfUnique(extraPaths, filepath.Dir(path)) 39 | } 40 | return nil 41 | }) 42 | if err != nil { 43 | return 44 | } 45 | os.Setenv("LD_LIBRARY_PATH", os.Getenv("LD_LIBRARY_PATH")+":"+strings.Join(extraPaths, ":")) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /validate.go: -------------------------------------------------------------------------------- 1 | // 2 | // This file is part of arduino-connector 3 | // 4 | // Copyright (C) 2017-2018 Arduino AG (http://www.arduino.cc/) 5 | // 6 | // Licensed under the Apache License, Version 2.0 (the "License"); 7 | // you may not use this file except in compliance with the License. 8 | // You may obtain a copy of the License at 9 | // 10 | // http://www.apache.org/licenses/LICENSE-2.0 11 | // 12 | // Unless required by applicable law or agreed to in writing, software 13 | // distributed under the License is distributed on an "AS IS" BASIS, 14 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | // See the License for the specific language governing permissions and 16 | // limitations under the License. 17 | // 18 | 19 | package main 20 | 21 | import ( 22 | "bytes" 23 | "crypto" 24 | "crypto/rsa" 25 | "crypto/sha256" 26 | "crypto/x509" 27 | "encoding/hex" 28 | "encoding/pem" 29 | "os" 30 | 31 | "github.com/pkg/errors" 32 | "golang.org/x/crypto/openpgp" 33 | ) 34 | 35 | // gpg --export YOURKEYID --export-options export-minimal,no-export-attributes | hexdump /dev/stdin -v -e '/1 "%02X"' 36 | var publicKeyHex = "99020D0452FAA2FA011000D0C5604932111750628F171E4E612D599ABEA8E4309888B9B9E87CCBD3AAD014B27454B0AF08E7CDD019DA72D492B6CF882AD7FA8571E985C538582DA096C371E7FCD95B71BC00C0E92BDDC26801F1B11C86814E0EA849E5973F630FC426E6A5F262C22986CB489B5304005202BA729D519725E3E6042C9199C8ECE734052B7376CF40A864679C3594C93203EBFB3F82CD42CD956F961792233B4C7C1A28252360F48F1D6D8662F2CF93F87DB40A99304F61828AF8A3EB07239E984629DC0B1D5C6494C9AFB5C8F8B9A53F1324C254A1AEA4CD9219AB4DF8667653AC9A6E76C3DB37CE8F60B546F78ECA43A90CB82A2278A291D2E98D66753B56F0595280C6E33B274F631846806D97447DD5C9438E7EC85779D9FA2173E088CE6FA156E291FAFD432C4FC2B1EB251DAFD13C721FF6618F696B77C122FB75E3CBCB446FAAA7FFFDD071C81C6C3D2360D495964C623617352915BBB30FA7C903EA096BF01E77FC84C8B51C02EB11BC9F03F19C7E81254F1F786E64A7A9F7F438873583CFA40F49C0F041432EAECCEC7EE9BA465A30320306F0E2E65EBE01E533CBBD8B1C1C04222213D5D05F4B689193DB60A68A6F1FC8B2ADD9E58A104E482AAD3869CCC42236EDC9CBE8561E105837AB6F7A764DCE5D8CB62608E8133F0FDD5F8FAFBE3BC57EE551ADC7386AADD443B331716EC032ACF9C639BF9DFE62301D4F197221F10DEF0011010001B42041726475696E6F204C4C43203C737570706F72744061726475696E6F2E63633E890238041301020022050252FAA2FA021B03060B090807030206150802090A0B0416020301021E01021780000A09107BAF404C2DFAB4AEF8090FFE20C3B36BF786D692969DA2ECFD7BCA3961E735D3CBB5585D7AB04BB8A0B64B64528ED76DB4752FA24523AA1E07B69A6A66CDDAE074A6A572800228194DD5916A956BF22606D866C7FD81F32878E06FEC200DDB0703D805E1A61006EB0B5BDB3AA89C095BB259BD93C7AAE8BDB18468A6DBE30F85BD6A3271F5456EB22BC2BCE99DB3A054D9BCA8F562C01B99E6BF4C2136B62771EEF54CB2AE95F8E2FE543284C37EB77E5104D49939ABAEF323CA5F1A66CA48ED423DBB3A2CFF12792CCA71ACD1E3032186CC7D05A13E0D66A3258E443527AAF921B7EA70C6CC10E2A51FCAB4DD130A10D3D29B1B01FB4207EF6501D3A9186BDB652ECCC9F354599A114DD3F80F9ED3493AC51A5C4F1F3BB59049EE7EC61411E90E02F27789E87B18A860551DFDFFA870E8542F6128E167CE1875C5C5B1128259347B85265487006B173AA631F1CDA1EDC68C54978E1D0FE3B310CC0F49F9AE84F37B1472437B69DA125BAFDC99AE57C2245F70747E1EFD52849C40469247CF13CB679A31AF4700468E09ED1ECFE5A53F67C80C48A0B0C1334FAE9650584DFD406ADA30FFBEED659256D40924432B029BBB24CEF22195D389381F0B1EB964C6494942335E74A373D869D1FB0C7967F30F79D71AB06929CEBB660514C2567284BD9EC32470B263539B3AFF5D3FBA9A275D4665E6B502B4031B63F511C1DFDD16B617A6FB046FCEB018A7A01CEFB9020D0452FAA2FA011000D6DE1747395EB3836103D30FA5CF555F6FBC982FB8B0FD72389CD6E99A88ACA1BCBD8BAD35211929AB5AB7F656BA1AFFA8C9A5AF83436FC8FE36AB403453E3E6EC679371AD81657FA1506956B1165D8887E3FB7EF366EFCCA82EE543E0B22170D0164A6702EF5280398A901CB6262E63C0AE378FD8CA1957EEED9CE48AA3D481BD117A2CA0341C3E16FE20CB6A5C3130A19B364F656CDC45E2216DE7ACFAD429967D71D101CADE10BA64F4075801ED2E9E3A3293114543456A26236CCA459DC7700D2E9C692BADCA9BA0CDE7189CD594B20CA4D1F20A70B02B9B50F70CFC6F7697B1D500702CE29492C7CD28C5D555475788DDE57482BC39E8465A720E25866AC931D5D7030AB61136BF702B25BC850A5089D1E6F0F68B8AE894ADFC3C92BB836888E3DB5A940426DBE7BBC5BDD3DDD6F5123627D1CE6FD1845CC66A920094391BE783069CB05746C0A55DAFC869FDAF0A08F81099E4F4CD07D05C7269C538C341CF1EDB94114B8CD97B44214EA58EEDB93FAB772013A1D77A08B9208082F9617A6CFE39B56F0078406C6267ABF5CF1078C49B1AB9B60EA1451351CF889EF72D7D696B23B22F753B28979AF10237B579A350FA5596A3B22244FA91402562AE530E814EF19A9E3448F465F78C16220DE0663F7B97C7F0EF1629E2F64A76B21BB695A3DE505B22B09B3459A3CE2180424BD67C8482EBD5EBC8128F98634EEE8707001101000189021F041801020009050252FAA2FA021B0C000A09107BAF404C2DFAB4AE050B1000C1434E8CC0D6F8E60E2FB091AA5EA04E7612B29D3823E09914F704DE1835A7B202D3F619183BD3A16439BFA31A6AF342672E8F59184333C4F56D18AF3B7CE8326F655F7C8DD1D4B38A1964E6A4D7550D159CE1B5EC44BC2091B1097CABE724C0E8C4942C2CF82672E3F209322270D133313CF601E07756B705946A45235DAF7294BCD34292D989EFDFDA2F46AF0AEAEC72F55DC6B2940C7C6A409B7FAD3354D5CA30C3E4EE29F9218A56EF8D7FBA2A7BB8E6304110A21DF0C847C4B761CDE408CE156D53091535A800C1C522CA33C71105B11550A145FD0E41B464146B46D46F08DFAEF9B03D313D54A1E4A82E8749895AB78521DAA8E66EEF6F7B17A0CA4B4CBFCB937713B9806269556EBD88AE87996EFAC0846ACBA0D3412FC0A5E90923C261CD443E4D6C1AE93D83166937C5F606A14FD73DB4919A0ED416D4B3163420F57FACCE9C9347BD5501BE3FC830472B64068E5FF5B09E7425030625246720D21608DEE829F84E8365527F764C91DA93372C72AA4054B458104CAFC2BDCED63DC80F36E7BD4BE0D3A19E20E3FED90F80F9E1584853B971B8E847C27027123B9AA19C3E90B41B3A643D3D5BE2FC134ADA8396D072D37E7101B64CE83E1802D0D5DDA9150B6C21564987950C9601FC2147F139C7A9906640A0883981B452F25AF7A0F32FAA2148ECDD9B04B93AFCED00F11AA0E6695C2F92676B8DB9E93172FD7779B9020D04530B05A7011000CAA1A8FF4BF9D0F0AC9EDBCA3B4D26E3E569DFEA04341F3E6ACE23AE5D87D62C2600DFF10B106144A1B52FF8B695A590D65C681F69DEE454800C235160EBE3FC1436193E1278D56C86E2BBB2187BEAAC1E1D04D40A392B1457471D10A2B6BF39CDF35D1A090A9406BCB06BDEF83A12A490C5E17D68884AD2686042669E2B458AD3CC0377DDA9C58D7070CE29A53E0E7C876D61B29A2DE2A9D73F914D0FF3B0E35E2ED361B60A8C3C3D4C7E77E17A939283BFDA2EC5725A2BFAAC18C6A64ACBEC776760D7086EA42BD93031E8B59FB8DFEFF77E5F80DBEB84ADE74B3A6F9E4D0F3140A8D0F576ED00548883C85271AA7F2450D1061F56CB839786038861D5A2473B7F58EBC00D2BB9EFEB1A2DF612A7B9087C326FBB08F2879102253316784272967A886089D61D5AB0FDB33737D35F27C2886ABB4D4E88F541D0BBAD04AEF7BD3ED66A1282B762BD6F8EEDC3760773B157C1A2D4E4586E43B28879C54E7599F9A34E1524E6E7F9B8EA13CC5A2DF5C1920AF74833EDDEC8EB9A8BE33196702DFD656D81ACBBFE3A10DA882EAA3065D9C9476C0A7B66C15D0063CB7AD1A2EB31537CB443F21B81642436943FE6C45E6AF9C2B595D4DFCB64B83F2CA6B4DD536726C6EC4761A340C18E32B2D7210640B9AB1D8E2165C0DD38BC9FD9DB6A30B380DF08C3F10002A6636FDC79CD2312B606F5F116AC665618A56BBE46C494FC7E23C7001101000189043E0418010200090502530B05A7021B02022909107BAF404C2DFAB4AEC15D200419010200060502530B05A7000A091024A26BAD7F29429187700FFE30ED1B7C96B3846AC7B363F9602D2886F7913A9C451C31E043AD75597024D460B59E6A60A6EE3D58E656901237A2465F8402169A816B38170AF550284EB420B7E827386D66852D68125A27FA6770F139EE7FCAEF43000673B7C7D168614877603C875AD593E333AE9237DB77065FB8375CE98FA1BF7FB1733034AAC61F1D23A3EFF8665702C10968C7991458F88D151B3448C7D9334059431A63D30A9C8E636A99D88DA8DB04CB8C64F1183AC873FF0942EF9555B6B3F192AD5F221AC9737F875CCAE21E88EC45CB35E40C0FF1AAF0A8FE44876D93A930A03CC4846A29102C956F39F2AC5808CCBCD7F4868A8E8E8B9A66EA18C275CEF9C371AB0592796ED57D757A3BAB31FF8E3887F6041E61BDA433E7D68CB2D5F28E81F57843D5032D73BF67119C137FC4CE8BEF4F705D690E47A530B1A85B8B6A09A4AE16A2973C11D69031B89BE92B0751DB7FE74F6F1C219C8B93E5C68EC1403856DF28E96E27737A7FB9C80F6EE9EC485A0609DC4EB8DF444F61C76A97F32ADFA2D8B4784DF3ABA4DE1B57894B9CF89934A143451308D73CF79ECC8BF382B8A34F24DC335238D8353767B363F5432D9A81C84F7D2FAB6E36E7188FA911120A905C67342A996251EBECAC13BD543A9B3C2C063AE294FDD15C66D5DD9224F3E936325F525700F2129D0B31CE8CCD4EBA5DEDB89F0A2BFC0C43E732F695161E4F33CE5DED14B1E98654547B110FFF7CBC2BA513721A96DD18964635069343FA8EEF4D492BFA55C930F9C78DF1F7454F1BDD40F4B04BDE9F9B9A9923A303D96D0CBFA361921AFEF13AED098D0CF70E84C0DDB20C58821351D2359B131671AAF5D2484717A4CAF385DB0CC19FBC37A3FC04F4F387D6934C1E84B9C1291231A14F69A1BF6708875C7DE00E3EFE3C7855A2459C96245C5F0D21FC00E87A0C18F80A3B79C0E28EA27493309C535254421BE7CDFBEFB5B44DAEA56B6859430FCCBEE766048F891AD5CB503866B98E521ED69B37E4165012A45E29836E2A0380728C1108E4C8A32EA186E1A855F78DA5506B6CF86DB888A87FAB6E15A90E3416469522DF5BD8872D729B35E6D82C974CD80076C26008015AB216C83FAF64E488F07D2BD01F51B0963F87BE0AB8392B442227BF7215148038B0C55189024D7C1B032DB1B3C56C66953E530C5B323634FC584A476CAD285EF1108011D14D9D180A75A9DFC936AFC7EF9E6C3F3CFEDD894894CE60358E7156B3A65ED7644DEA343A133F5D4DE4D33B74281086A0C20515AC4151CFED93C56DD574E578FDEE72C4115C25CAEC5EAD97C147F27F4EAE67FEFFEA0DC1CDF5D636AC331CB74DF477C9C3B3706F9DAF50C2E13AC8DE8CC9DD3C79E59EC779EE489D915CF22FDC53E3B3C7710FE8368DF11B9ACDF5F3CAE1F43CB7312E5E9F57F248692B3681CBA3E49207878FD33ED2A47CE9CE9B4E4A6EFD8F0AD2CD" 37 | 38 | func checkGPGSig(fileName string, sigFileName string) error { 39 | 40 | // Get a Reader for the signature file 41 | sigFile, err := os.Open(sigFileName) 42 | if err != nil { 43 | return err 44 | } 45 | defer sigFile.Close() 46 | 47 | // Get a Reader for the signature file 48 | file, err := os.Open(fileName) 49 | if err != nil { 50 | return err 51 | } 52 | defer file.Close() 53 | 54 | publicKeyBin, err := hex.DecodeString(publicKeyHex) 55 | if err != nil { 56 | return err 57 | } 58 | 59 | keyring, _ := openpgp.ReadKeyRing(bytes.NewReader(publicKeyBin)) 60 | 61 | _, err = openpgp.CheckDetachedSignature(keyring, file, sigFile) 62 | 63 | return err 64 | } 65 | 66 | func verifyBinary(input []byte, signature []byte, signatureKey string) error { 67 | block, _ := pem.Decode([]byte(signatureKey)) 68 | if block == nil { 69 | return errors.New("invalid key") 70 | } 71 | key, err := x509.ParsePKIXPublicKey(block.Bytes) 72 | if err != nil { 73 | return err 74 | } 75 | rsaKey := key.(*rsa.PublicKey) 76 | d := sha256.Sum256(input) 77 | return rsa.VerifyPKCS1v15(rsaKey, crypto.SHA256, d[:], signature) 78 | } 79 | --------------------------------------------------------------------------------