From bcd5d2de8c1d09de75bbbaf8e00448414349713a Mon Sep 17 00:00:00 2001 From: mikedickey Date: Fri, 3 Sep 2021 17:00:55 -0700 Subject: [PATCH] Merge develop to main (#13) * Don't try to load alsa state if the file doesn't exist Improvement some of the REST api endpoints * Update to get google cloud instance name on audio servers * Update to get azure cloud instance name on audio servers * Adding --broadcast 1024 to all jacktrip server instances Updated copyrights for JackTrip Labs * Refactoring code * Incorporating github actions * Fixed a few bugs in device agent code caused by last refactor * Removing jack-plumbing, jacktrip-receive and jacktrip-send services. (#3) * Removing jack-plumbing, jacktrip-receive and jacktrip-send services. All jacktrip audio will now always passed through SuperCollider, regardless of server size. Using scsynth instead of supernova, for the time being. Still testing and comparing.. * Use supernova for < 50 logical cores, and scsynth for more. supernova generally consumes more resources but handles larger scale mixes at lower core/musician counts. scsynth consumes fewer resources and generally works better for simple mixes (up to 480+). * A few more supercollider parameter tweaks * Changing server port (#6) * Changing server port * Less boring * Adding some basic tests (#5) * Fixing test failure (#8) * Bump wire buffer limit for supercollider, and always use scsynth (#12) * Bump wire buffer limit for supercollider, and always use scsynth since it works better with the new sc mixer code. * Remove supercollider -T parameter since it doesn't work for scsynth Co-authored-by: nwang92 Co-authored-by: Nelson Wang --- .github/workflows/ci.yml | 42 + .github/workflows/secret-scan-denylist.txt | 3 + .gitignore | 3 +- Makefile | 5 + cmd/credentials.go | 57 ++ cmd/device.go | 497 ++++++++++ cmd/handlers.go | 91 ++ cmd/handlers_test.go | 157 +++ cmd/logging.go | 24 + cmd/logging_test.go | 27 + cmd/main.go | 1042 +------------------- cmd/ping.go | 69 ++ cmd/server.go | 258 +++++ cmd/services.go | 319 ++++++ go.mod | 4 +- go.sum | 5 + pkg/client/devices.go | 2 +- pkg/client/devices_test.go | 145 +++ pkg/client/servers.go | 2 +- pkg/client/servers_test.go | 48 + 20 files changed, 1755 insertions(+), 1045 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/secret-scan-denylist.txt create mode 100644 cmd/credentials.go create mode 100644 cmd/device.go create mode 100644 cmd/handlers.go create mode 100644 cmd/handlers_test.go create mode 100644 cmd/logging.go create mode 100644 cmd/logging_test.go create mode 100644 cmd/ping.go create mode 100644 cmd/server.go create mode 100644 cmd/services.go create mode 100644 pkg/client/devices_test.go create mode 100644 pkg/client/servers_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..86f4b55 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,42 @@ +name: Agent CI + +on: + push: + pull_request: + branches: + - develop + - main + +jobs: + build: + name: Verify code + runs-on: ubuntu-20.04 + steps: + - name: Checkout code + uses: actions/checkout@v2 + - name: Setup golang + uses: actions/setup-go@v2 + with: + go-version: 1.16.x + - name: Scan hardcoded secrets + uses: max/secret-scan@master + with: + exclude_path: '.github/workflows/secret-scan-denylist.txt' + - name: Format and lint + run: | + go env -w GOFLAGS=-mod=mod + go get -u golang.org/x/lint/golint + make fmt + make lint + - name: Small tests + run: | + go env -w GOFLAGS=-mod=mod + go get gotest.tools/gotestsum + make small-tests + - name: Report + uses: mikepenz/action-junit-report@v2 + with: + check_name: Small tests + report_paths: 'artifacts/results-small.xml' + - name: Build + run: make agent-amd64 diff --git a/.github/workflows/secret-scan-denylist.txt b/.github/workflows/secret-scan-denylist.txt new file mode 100644 index 0000000..fda3b98 --- /dev/null +++ b/.github/workflows/secret-scan-denylist.txt @@ -0,0 +1,3 @@ +go.sum +cmd/credentials.go +pkg/client/devices_test.go diff --git a/.gitignore b/.gitignore index aa8bc91..981e474 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ jacktrip-agent jacktrip-agent-amd64 -jacktrip-agent-arm \ No newline at end of file +jacktrip-agent-arm +artifacts/ diff --git a/Makefile b/Makefile index 972c907..e424111 100644 --- a/Makefile +++ b/Makefile @@ -27,3 +27,8 @@ fmt: lint: @golint ./... + +small-tests: + @go clean -testcache + @mkdir -p artifacts + @gotestsum -f standard-verbose --junitfile artifacts/results-small.xml -- -coverprofile=artifacts/coverage.out -tags=unit ./... diff --git a/cmd/credentials.go b/cmd/credentials.go new file mode 100644 index 0000000..b442820 --- /dev/null +++ b/cmd/credentials.go @@ -0,0 +1,57 @@ +// Copyright 2020-2021 JackTrip Labs, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "bytes" + "fmt" + "io/ioutil" + "math/rand" + "time" + + "github.com/jacktrip/jacktrip-agent/pkg/client" +) + +const ( + // SecretBytes are used to generate random secret strings + SecretBytes = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" +) + +func init() { + // seed random number generator for secret generation + rand.Seed(time.Now().UnixNano()) +} + +// getCredentials retrieves jacktrip agent credentials from system config file. +// If config does not exist, it will generate and save new credentials to config file. +func getCredentials() client.AgentCredentials { + + rawBytes, err := ioutil.ReadFile(fmt.Sprintf("%s/credentials", AgentConfigDir)) + if err != nil { + log.Error(err, "Failed to read credentials") + panic(err) + } + + splits := bytes.Split(bytes.TrimSpace(rawBytes), []byte(".")) + if len(splits) != 2 || len(splits[0]) < 1 || len(splits[1]) < 1 { + log.Error(err, "Failed to parse credentials") + panic(err) + } + + return client.AgentCredentials{ + APIPrefix: string(splits[0]), + APISecret: string(splits[1]), + } +} diff --git a/cmd/device.go b/cmd/device.go new file mode 100644 index 0000000..ce4ca5a --- /dev/null +++ b/cmd/device.go @@ -0,0 +1,497 @@ +// Copyright 2020-2021 JackTrip Labs, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "fmt" + "io/ioutil" + "net/http" + "os" + "os/exec" + "strings" + "sync" + "time" + + goping "github.com/go-ping/ping" + "github.com/gorilla/mux" + + "github.com/jacktrip/jacktrip-agent/pkg/client" +) + +const ( + // PathToAvahiServiceFile is the path to the avahi service file for jacktrip-agent + PathToAvahiServiceFile = "/tmp/avahi/services/jacktrip-agent.service" + + // JackDeviceConfigTemplate is the template used to generate /tmp/default/jack file on raspberry pi devices + JackDeviceConfigTemplate = "JACK_OPTS=-dalsa -dhw:%s --rate %d --period %d\n" + + // JackTripDeviceConfigTemplate is the template used to generate /tmp/default/jacktrip file on raspberry pi devices + JackTripDeviceConfigTemplate = "JACKTRIP_OPTS=-t -z --udprt -n %d -C %s --peerport %d --bindport %d --clientname hubserver --remotename %s %s\n" + + // JamulusDeviceConfigTemplate is the template used to generate /tmp/default/jamulus file on raspberry pi devices + JamulusDeviceConfigTemplate = "JAMULUS_OPTS=-n -i /tmp/jamulus.ini -c %s:%d\n" + + // DevicesRedirectURL is a template used to construct UI redirect URL for this device + DevicesRedirectURL = "https://app.jacktrip.org/devices/%s?apiPrefix=%s&apiHash=%s" + + // PathToMACAddress is the path to ethernet device MAC address, via Linux kernel + PathToMACAddress = "/sys/class/net/eth0/address" + + // PathToAsoundCards is the path to the ALSA card list + PathToAsoundCards = "/proc/asound/cards" +) + +var soundDeviceName = "" +var soundDeviceType = "" +var lastDeviceStatus = "starting" + +// runOnDevice is used to run jacktrip-agent on a raspberry pi device +func runOnDevice(apiOrigin string) { + log.Info("Running jacktrip-agent in device mode") + + // get sound device name and type + soundDeviceName = getSoundDeviceName() + soundDeviceType = getSoundDeviceType() + log.Info("Detected sound device", "name", soundDeviceName, "type", soundDeviceType) + + // restore alsa card state, if saved state exists + alsaStateFile := fmt.Sprintf("%s/asound.%s.state", AgentLibDir, soundDeviceType) + if _, err := os.Stat(alsaStateFile); err == nil { + log.Info("Restoring ALSA state", "file", alsaStateFile) + cmd := exec.Command("/usr/sbin/alsactl", "restore", "--file", alsaStateFile) + if err := cmd.Run(); err != nil { + log.Error(err, "Unable to restore ALSA state", "file", alsaStateFile) + } + } + + // get mac and credentials + mac := getMACAddress() + credentials := getCredentials() + + // setup wait group for multiple routines + var wg sync.WaitGroup + + // start HTTP server to redirect requests + router := mux.NewRouter() + router.HandleFunc("/ping", handlePingRequest).Methods("GET") + router.PathPrefix("/info").Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handleDeviceInfoRequest(mac, credentials, w, r) + })).Methods("GET") + router.PathPrefix("/").Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handleDeviceRedirect(mac, credentials, w, r) + })).Methods("GET") + router.PathPrefix("/").Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + OptionsGetOnly(w, r) + })).Methods("OPTIONS") + wg.Add(1) + go runHTTPServer(&wg, router, ":80") + + // update avahi service config and restart daemon + ping := client.AgentPing{ + AgentCredentials: credentials, + MAC: mac, + Version: getPatchVersion(), + Type: soundDeviceType, + } + updateAvahiServiceConfig(ping, lastDeviceStatus) + + // start ping server to send pings and update agent config + wg.Add(1) + go runDevicePinger(&wg, ping, apiOrigin) + + // wait for everything to complete + wg.Wait() +} + +// runDevicePinger sends pings to service and manages config updates +func runDevicePinger(wg *sync.WaitGroup, ping client.AgentPing, apiOrigin string) { + defer wg.Done() + + log.Info("Starting agent ping server") + + getPingStats(&ping, nil) + + for { + config, err := sendPing(ping, apiOrigin) + if err != nil { + updateDeviceStatus(ping, "error") + panic(err) + } + + // check if config has changed + if config != lastConfig { + log.Info("Config updated", "value", config) + handleDeviceUpdate(ping, config) + } + + if config.Enabled && config.Host != "" { + // ping server instead of sleeping + pinger, err := goping.NewPinger(config.Host) + if err == nil { + log.V(2).Info("Pinging server", "host", config.Host) + pinger.Count = AgentPingInterval * 10 + pinger.Interval = time.Millisecond * 100 + pinger.Timeout = time.Second * AgentPingInterval + pinger.Run() + log.V(1).Info("Done pinging server", "host", config.Host, "stats", *pinger.Statistics()) + getPingStats(&ping, pinger.Statistics()) + } else { + log.Error(err, "Failed to create pinger") + // sleep in between pings + time.Sleep(time.Second * AgentPingInterval) + getPingStats(&ping, nil) + } + } else { + // sleep in between pings + time.Sleep(time.Second * AgentPingInterval) + getPingStats(&ping, nil) + } + } +} + +// handleDeviceUpdate handles updates to device configuratiosn +func handleDeviceUpdate(ping client.AgentPing, config client.AgentConfig) { + + log.Info("Config updated", "value", config) + + // update ALSA card settings + if config.ALSAConfig != lastConfig.ALSAConfig { + updateALSASettings(config) + } + + // check if ALSA card settings was the only change + lastConfig.ALSAConfig = config.ALSAConfig + if config != lastConfig { + // more changes required -> reset everything + + // update managed config files + updateServiceConfigs(config, strings.Replace(ping.MAC, ":", "", -1), false) + + // shutdown or restart managed services + restartAllServices(config, false) + } + + lastConfig = config + + // update device status in avahi service config, if necessary + if config.Enabled { + updateDeviceStatus(ping, "connected") + } else { + updateDeviceStatus(ping, "not connected") + } +} + +// getMACAddress retrieves ethernet device MAC address, via Linux kernel +func getMACAddress() string { + macBytes, err := ioutil.ReadFile(PathToMACAddress) + if err != nil { + log.Error(err, "Unable to retrieve MAC address") + panic(err) + } + + // trip whitespace and convert to lowercase + mac := strings.TrimSpace(string(macBytes)) + mac = strings.ToLower(mac) + + log.Info("Retrieved MAC address", "mac", mac) + return mac +} + +// getPatchVersion retrieves patch version for the device +func getPatchVersion() string { + rawBytes, err := ioutil.ReadFile(fmt.Sprintf("%s/patch", AgentConfigDir)) + if err != nil { + return "" + } + + // trim whitespace + patchVersion := strings.TrimSpace(string(rawBytes)) + + log.Info("Retrieved patch version", "version", patchVersion) + return patchVersion +} + +// getSoundDeviceName retrieves alsa name for the sound device +func getSoundDeviceName() string { + rawBytes, err := ioutil.ReadFile(fmt.Sprintf("%s/devicename", AgentConfigDir)) + if err != nil { + log.Error(err, "Unable to retrieve name of sound device") + panic(err) + } + return strings.TrimSpace(string(rawBytes)) +} + +// getSoundDeviceType retrieves alsa type for the sound device +func getSoundDeviceType() string { + rawBytes, err := ioutil.ReadFile(fmt.Sprintf("%s/devicetype", AgentConfigDir)) + if err != nil { + log.Error(err, "Unable to retrieve type of sound device") + panic(err) + } + return strings.TrimSpace(string(rawBytes)) +} + +// getPingStats updates and AgentPing message with go-ping Pinger stats +func getPingStats(ping *client.AgentPing, stats *goping.Statistics) { + ping.StatsUpdatedAt = time.Now() + + if stats == nil { + ping.PacketsRecv = 0 + ping.PacketsSent = 0 + ping.MinRtt = 0 + ping.MaxRtt = 0 + ping.AvgRtt = 0 + ping.StdDevRtt = 0 + return + } + + ping.PacketsRecv = stats.PacketsRecv + ping.PacketsSent = stats.PacketsSent + ping.MinRtt = stats.MinRtt + ping.MaxRtt = stats.MaxRtt + ping.AvgRtt = stats.AvgRtt + ping.StdDevRtt = stats.StdDevRtt +} + +// updateALSASettings is used to update the settings for an ALSA sound card +func updateALSASettings(config client.AgentConfig) { + switch soundDeviceType { + case "snd_rpi_hifiberry_dacplusadc": + fallthrough + case "snd_rpi_hifiberry_dacplusadcpro": + updateALSASettingsHiFiBerry(config) + case "audioinjector-pi-soundcard": + updateALSASettingsAudioInjector(config) + case "USB Audio Device": + updateALSASettingsUSBAudioDevice(config) + case "USB PnP Sound Device": + updateALSASettingsUSBPnPSoundDevice(config) + default: + log.Info("No ALSA alsa controls for sound device", "type", soundDeviceType) + } +} + +// updateALSASettings is used to update the settings for a HiFiBerry sound card +func updateALSASettingsHiFiBerry(config client.AgentConfig) { + var v int + amixerDevice := fmt.Sprintf("hw:%s", soundDeviceName) + + // ignore capture boost + /* + if config.CaptureBoost { + v = 104 + } else { + v = 0 + } + cmd := exec.Command("/usr/bin/amixer", "-D", amixerDevice, "cset", "name='PGA Gain Left'", "--", fmt.Sprintf("%d", v)) + if err := cmd.Run(); err != nil { + log.Error(err, "Unable to update 'PGA Gain Left'", "value", v) + } else { + log.Info("Updated 'PGA Gain Left'", "value", v) + } + cmd = exec.Command("/usr/bin/amixer", "-D", amixerDevice, "cset", "name='PGA Gain Right'", "--", fmt.Sprintf("%d", v)) + if err := cmd.Run(); err != nil { + log.Error(err, "Unable to update 'PGA Gain Right'", "value", v) + } else { + log.Info("Updated 'PGA Gain Right'", "value", v) + } + */ + + // set capture volume + // note: 'PGA Gain Left' and 'PGA Gain Right' appear to map directly to 'ADC Capture Volume' left & right + v = int(config.CaptureVolume * 104 / 100) + cmd := exec.Command("/usr/bin/amixer", "-D", amixerDevice, "cset", "name='ADC Capture Volume'", "--", fmt.Sprintf("%d,%d", v, v)) + if err := cmd.Run(); err != nil { + log.Error(err, "Unable to update 'ADC Capture Volume'", "value", v) + } else { + log.Info("Updated 'ADC Capture Volume'", "value", v) + } + + // set playback boost + if config.PlaybackBoost { + v = 1 + } else { + v = 0 + } + cmd = exec.Command("/usr/bin/amixer", "-D", amixerDevice, "cset", "name='Analogue Playback Volume'", "--", fmt.Sprintf("%d,%d", v, v)) + if err := cmd.Run(); err != nil { + log.Error(err, "Unable to update 'Analogue Playback Volume'", "value", v) + } else { + log.Info("Updated 'Analogue Playback Volume'", "value", v) + } + + // set playback volume + v = int(config.PlaybackVolume * 207 / 100) + cmd = exec.Command("/usr/bin/amixer", "-D", amixerDevice, "cset", "name='Digital Playback Volume'", "--", fmt.Sprintf("%d,%d", v, v)) + if err := cmd.Run(); err != nil { + log.Error(err, "Unable to update 'Digital Playback Volume' to %d: %s", "value", v) + } else { + log.Info("Updated 'Digital Playback Volume'", "value", v) + } +} + +// updateALSASettingsAudioInjector is used to update the settings for a Audio Injector Stereo sound card +func updateALSASettingsAudioInjector(config client.AgentConfig) { + var v int + amixerDevice := fmt.Sprintf("hw:%s", soundDeviceName) + + // enable built in mic with boost, if set + if config.CaptureBoost { + v = 1 + } else { + v = 0 + } + cmd := exec.Command("/usr/bin/amixer", "-D", amixerDevice, "cset", "name='Mic Boost Volume'", "--", fmt.Sprintf("%d", v)) + if err := cmd.Run(); err != nil { + log.Error(err, "Unable to update 'Mic Boost Volume'", "value", v) + } else { + log.Info("Updated 'Mic Boost Volume'", "value", v) + } + cmd = exec.Command("/usr/bin/amixer", "-D", amixerDevice, "cset", "name='Input Mux'", "--", fmt.Sprintf("%d", v)) + if err := cmd.Run(); err != nil { + log.Error(err, "Unable to update 'Input Mux'", "value", v) + } else { + log.Info("Updated 'Input Mux'", "value", v) + } + + // set capture volume + v = int(config.CaptureVolume * 31 / 100) + cmd = exec.Command("/usr/bin/amixer", "-D", amixerDevice, "cset", "name='Capture Volume'", "--", fmt.Sprintf("%d", v)) + if err := cmd.Run(); err != nil { + log.Error(err, "Unable to update 'Capture Volume'", "value", v) + } else { + log.Info("Updated 'Capture Volume'", "value", v) + } + + // set playback volume + v = int(config.PlaybackVolume * 127 / 100) + cmd = exec.Command("/usr/bin/amixer", "-D", amixerDevice, "cset", "name='Master Playback Volume'", "--", fmt.Sprintf("%d,%d", v, v)) + if err := cmd.Run(); err != nil { + log.Error(err, "Unable to update 'Master Playback Volume' to %d: %s", "value", v) + } else { + log.Info("Updated 'Master Playback Volume'", "value", v) + } +} + +// updateALSASettingsUSBAudioDevice is used to update the settings for a USB sound card +func updateALSASettingsUSBAudioDevice(config client.AgentConfig) { + var v int + amixerDevice := fmt.Sprintf("hw:%s", soundDeviceName) + + // set capture volume + v = int(config.CaptureVolume * 35 / 100) + cmd := exec.Command("/usr/bin/amixer", "-D", amixerDevice, "cset", "name='Mic Capture Volume'", "--", fmt.Sprintf("%d", v)) + if err := cmd.Run(); err != nil { + log.Error(err, "Unable to update 'Mic Capture Volume'", "value", v) + } else { + log.Info("Updated 'Mic Capture Volume'", "value", v) + } + + // set playback volume + v = int(config.PlaybackVolume * 37 / 100) + cmd = exec.Command("/usr/bin/amixer", "-D", amixerDevice, "cset", "name='Speaker Playback Volume'", "--", fmt.Sprintf("%d,%d", v, v)) + if err := cmd.Run(); err != nil { + log.Error(err, "Unable to update 'Speaker Playback Volume' to %d: %s", "value", v) + } else { + log.Info("Updated 'Speaker Playback Volume'", "value", v) + } +} + +// updateALSASettingsUSBPnPSoundDevice is used to update the settings for a USB sound card +func updateALSASettingsUSBPnPSoundDevice(config client.AgentConfig) { + var v int + amixerDevice := fmt.Sprintf("hw:%s", soundDeviceName) + + // set capture volume + v = int(config.CaptureVolume * 16 / 100) + cmd := exec.Command("/usr/bin/amixer", "-D", amixerDevice, "cset", "name='Mic Capture Volume'", "--", fmt.Sprintf("%d", v)) + if err := cmd.Run(); err != nil { + log.Error(err, "Unable to update 'Mic Capture Volume'", "value", v) + } else { + log.Info("Updated 'Mic Capture Volume'", "value", v) + } + + // set playback volume + v = int(config.PlaybackVolume * 151 / 100) + cmd = exec.Command("/usr/bin/amixer", "-D", amixerDevice, "cset", "name='Speaker Playback Volume'", "--", fmt.Sprintf("%d,%d", v, v)) + if err := cmd.Run(); err != nil { + log.Error(err, "Unable to update 'Speaker Playback Volume' to %d: %s", "value", v) + } else { + log.Info("Updated 'Speaker Playback Volume'", "value", v) + } +} + +// updateAvahiServiceConfig generates a new /etc/avahi/services/jacktrip-agent.service file +func updateAvahiServiceConfig(ping client.AgentPing, status string) { + // ensure config directory exists + err := os.MkdirAll("/tmp/avahi/services", 0755) + if err != nil { + log.Error(err, "Failed to create directory", "path", "/tmp/avahi/services") + return + } + + apiHash := client.GetAPIHash(ping.APISecret) + avahiServiceConfig := fmt.Sprintf(` + + + JackTrip Agent on %%h + + _http._tcp + 80 + status=%s + version=%s + mac=%s + apiHash=%s + + +`, status, ping.Version, ping.MAC, apiHash) + + err = ioutil.WriteFile(PathToAvahiServiceFile, []byte(avahiServiceConfig), 0644) + if err != nil { + log.Error(err, "Failed to save avahi service config", "path", PathToAvahiServiceFile) + return + } +} + +// updateDeviceStatus updates the device status, including avahi config, if it has changed +func updateDeviceStatus(ping client.AgentPing, status string) { + if lastDeviceStatus != status { + updateAvahiServiceConfig(ping, status) + lastDeviceStatus = status + } +} + +// handleDeviceInfoRequest returns information about a device +func handleDeviceInfoRequest(mac string, credentials client.AgentCredentials, w http.ResponseWriter, r *http.Request) { + apiHash := client.GetAPIHash(credentials.APISecret) + deviceInfo := struct { + APIPrefix string `json:"apiPrefix"` + APIHash string `json:"apiHash"` + MAC string `json:"mac"` + }{ + APIPrefix: credentials.APIPrefix, + APIHash: apiHash, + MAC: mac, + } + RespondJSON(w, http.StatusOK, deviceInfo) +} + +// handleDeviceRedirect redirects all requests to devices in jacktrip web application +func handleDeviceRedirect(mac string, credentials client.AgentCredentials, w http.ResponseWriter, r *http.Request) { + apiHash := client.GetAPIHash(credentials.APISecret) + w.Header().Set("Location", fmt.Sprintf(DevicesRedirectURL, mac, credentials.APIPrefix, apiHash)) + w.Header().Set("Access-Control-Allow-Origin", "*") + w.WriteHeader(http.StatusSeeOther) +} diff --git a/cmd/handlers.go b/cmd/handlers.go new file mode 100644 index 0000000..9216562 --- /dev/null +++ b/cmd/handlers.go @@ -0,0 +1,91 @@ +// Copyright 2020-2021 JackTrip Labs, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "encoding/json" + "net/http" + "sync" + + "github.com/gorilla/mux" + "github.com/gorilla/websocket" +) + +// runHTTPServer runs the agent's HTTP server +func runHTTPServer(wg *sync.WaitGroup, router *mux.Router, address string) error { + defer wg.Done() + log.Info("Starting agent HTTP server") + err := http.ListenAndServe(address, router) + if err != nil { + log.Error(err, "HTTP server error") + } + return err +} + +// handlePingRequest upgrades ping request to a websocket responder +func handlePingRequest(w http.ResponseWriter, r *http.Request) { + // return success if no request for websocket + if r.Header.Get("Connection") != "Upgrade" || r.Header.Get("Upgrade") != "websocket" { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Access-Control-Allow-Origin", "*") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"status":"OK"}`)) + return + } + + // upgrade to websocket + upgrader := websocket.Upgrader{} + c, err := upgrader.Upgrade(w, r, nil) + if err != nil { + log.Error(err, "Unable to upgrade to websocket") + return + } + defer c.Close() + for { + mt, message, err := c.ReadMessage() + if err != nil { + log.Error(err, "Unable to read websocket message") + break + } + err = c.WriteMessage(mt, message) + if err != nil { + log.Error(err, "Unable to write websocket message") + break + } + } +} + +// OptionsGetOnly responds with a list of allow methods for Get only +func OptionsGetOnly(w http.ResponseWriter, r *http.Request) { + allowMethods := "GET, OPTIONS" + w.Header().Set("Allow", allowMethods) + w.Header().Set("Access-Control-Allow-Methods", allowMethods) + w.Header().Set("Access-Control-Allow-Origin", "*") + w.WriteHeader(http.StatusOK) +} + +// RespondJSON makes the response with payload as json format +func RespondJSON(w http.ResponseWriter, status int, payload interface{}) { + response, err := json.Marshal(payload) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(err.Error())) + return + } + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Access-Control-Allow-Origin", "*") + w.WriteHeader(status) + w.Write([]byte(response)) +} diff --git a/cmd/handlers_test.go b/cmd/handlers_test.go new file mode 100644 index 0000000..dcc6c36 --- /dev/null +++ b/cmd/handlers_test.go @@ -0,0 +1,157 @@ +// Copyright 2020-2021 JackTrip Labs, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "github.com/stretchr/testify/assert" + "io" + "net/http/httptest" + "testing" +) + +type TestPayload struct { + Name string +} + +type TestResponseHeaders struct { + header string + result string +} + +func TestRunHTTPServer(t *testing.T) { + t.Skip("TODO") +} + +func TestHandlePingRequestNoWS(t *testing.T) { + assert := assert.New(t) + // Instantiate mock response writer and request to exercise method + mockResp := httptest.NewRecorder() + mockReq := httptest.NewRequest("GET", "http://example.com/foo", nil) + mockReq.Header.Set("Connection", "nothing") + mockReq.Header.Set("Upgrade", "nada") + handlePingRequest(mockResp, mockReq) + resp := mockResp.Result() + body, _ := io.ReadAll(resp.Body) + + tests := []TestResponseHeaders{ + // Check Content-Type header + { + header: "Content-Type", + result: "application/json", + }, + // Check Access-Control-Allow-Origin header + { + header: "Access-Control-Allow-Origin", + result: "*", + }, + } + + assert.Equal(200, resp.StatusCode) + assert.Equal(`{"status":"OK"}`, string(body)) + for _, param := range tests { + assert.Equal(param.result, resp.Header.Get(param.header)) + } +} + +func TestOptionsGetOnly(t *testing.T) { + assert := assert.New(t) + // Instantiate mock response writer and request to exercise method + mockResp := httptest.NewRecorder() + mockReq := httptest.NewRequest("GET", "http://example.com/foo", nil) + OptionsGetOnly(mockResp, mockReq) + resp := mockResp.Result() + + tests := []TestResponseHeaders{ + // Check Allow header + { + header: "Allow", + result: "GET, OPTIONS", + }, + // Check Access-Control-Allow-Methods header + { + header: "Access-Control-Allow-Methods", + result: "GET, OPTIONS", + }, + // Check Access-Control-Allow-Origin header + { + header: "Access-Control-Allow-Origin", + result: "*", + }, + } + + assert.Equal(200, resp.StatusCode) + for _, param := range tests { + assert.Equal(param.result, resp.Header.Get(param.header)) + } +} + +func TestRespondJSONValid(t *testing.T) { + assert := assert.New(t) + // Instantiate mock response writer to exercise method + mockResp := httptest.NewRecorder() + payload := TestPayload{"mr-worldwide"} + RespondJSON(mockResp, 299, payload) + resp := mockResp.Result() + body, _ := io.ReadAll(resp.Body) + + tests := []TestResponseHeaders{ + // Check Content-Type header + { + header: "Content-Type", + result: "application/json", + }, + // Check Access-Control-Allow-Origin header + { + header: "Access-Control-Allow-Origin", + result: "*", + }, + } + + assert.Equal(299, resp.StatusCode) + assert.Equal(`{"Name":"mr-worldwide"}`, string(body)) + for _, param := range tests { + assert.Equal(param.result, resp.Header.Get(param.header)) + } +} + +func TestRespondJSONInvalid(t *testing.T) { + assert := assert.New(t) + // Instantiate mock response writer to exercise method + mockResp := httptest.NewRecorder() + // Bad payload should result in an error + payload := make(chan int) + RespondJSON(mockResp, 298, payload) + resp := mockResp.Result() + body, _ := io.ReadAll(resp.Body) + + tests := []TestResponseHeaders{ + // Check Content-Type header + { + header: "Content-Type", + result: "", + }, + // Check Access-Control-Allow-Origin header + { + header: "Access-Control-Allow-Origin", + result: "", + }, + } + + assert.Equal(500, resp.StatusCode) + assert.Equal("json: unsupported type: chan int", string(body)) + for _, param := range tests { + assert.Equal(param.result, resp.Header.Get(param.header)) + } +} diff --git a/cmd/logging.go b/cmd/logging.go new file mode 100644 index 0000000..3d5f493 --- /dev/null +++ b/cmd/logging.go @@ -0,0 +1,24 @@ +// Copyright 2020-2021 JackTrip Labs, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "github.com/go-logr/zapr" + "go.uber.org/zap" +) + +//var log = stdr.New(stdlog.New(os.Stderr, "", stdlog.LstdFlags|stdlog.Lshortfile)).WithName("jacktrip.agent") +var zLogger, _ = zap.NewProduction() +var log = zapr.NewLogger(zLogger).WithName("jacktrip.agent") diff --git a/cmd/logging_test.go b/cmd/logging_test.go new file mode 100644 index 0000000..2395b76 --- /dev/null +++ b/cmd/logging_test.go @@ -0,0 +1,27 @@ +// Copyright 2020-2021 JackTrip Labs, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "github.com/stretchr/testify/assert" + "testing" +) + +func TestLog(t *testing.T) { + assert := assert.New(t) + assert.NotNil(zLogger) + assert.NotNil(log) + log.Info("testing logger") +} diff --git a/cmd/main.go b/cmd/main.go index 5bd70f0..434233e 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -1,4 +1,4 @@ -// Copyright 2020 20hz, LLC +// Copyright 2020-2021 JackTrip Labs, Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -15,155 +15,18 @@ package main import ( - "bufio" - "bytes" - "encoding/json" - "errors" "flag" - "fmt" - "io/ioutil" - "math/rand" - "net/http" "os" - "os/exec" - "regexp" - "runtime" - "strings" - "sync" - "time" - - "github.com/coreos/go-systemd/v22/dbus" - "github.com/go-logr/zapr" - goping "github.com/go-ping/ping" - "github.com/gorilla/mux" - "github.com/gorilla/websocket" - "go.uber.org/zap" - - "github.com/jacktrip/jacktrip-agent/pkg/client" ) const ( - // LocalHTTPServer is the :port the HTTP server listens on - LocalHTTPServer = ":80" - - // AgentPingURL is the URL used to POST agent pings - AgentPingURL = "/agents/ping" - - // AgentPingInterval is number of seconds in between agent ping requests - AgentPingInterval = 5 - - // MaxClientsPerProcessor is the number of JackTrip clients to support per logical processor - MaxClientsPerProcessor = 6 // add 1 (20%) for Jamulus bridge, etc - - // JackServiceName is the name of the systemd service for Jack - JackServiceName = "jack.service" - - // SuperColliderServiceName is the name of the systemd service for the SuperCollider server - SuperColliderServiceName = "supernova.service" - - // SCLangServiceName is the name of the systemd service for the SuperCollider language runtime - SCLangServiceName = "sclang.service" - - // JackAutoconnectServiceName is the name of the systemd service for connecting jack clients - JackAutoconnectServiceName = "jack-autoconnect.service" - - // JackPlumbingServiceName is the name of the systemd service for connecting jack clients - JackPlumbingServiceName = "jack-plumbing.service" - - // JackTripReceiveServiceName is the name of the systemd service for connecting jack client inputs - JackTripReceiveServiceName = "jacktrip-receive.service" - - // JackTripSendServiceName is the name of the systemd service for connecting jack client outputs - JackTripSendServiceName = "jacktrip-send.service" - - // JackTripServiceName is the name of the systemd service for JackTrip - JackTripServiceName = "jacktrip.service" - - // JamulusServiceName is the name of the systemd service for Jamulus client on RPI devices - JamulusServiceName = "jamulus.service" - - // JamulusServerServiceName is the name of the systemd service for the Jamulus server - JamulusServerServiceName = "jamulus-server.service" - - // JamulusBridgeServiceName is the name of the systemd service for the Jamulus -> JackTrip bridge - JamulusBridgeServiceName = "jamulus-bridge.service" - - // PathToJackConfig is the path to Jack service config file - PathToJackConfig = "/tmp/default/jack" - - // PathToJackTripConfig is the path to JackTrip service config file - PathToJackTripConfig = "/tmp/default/jacktrip" - - // PathToJamulusConfig is the path to Jamulus service config file - PathToJamulusConfig = "/tmp/default/jamulus" - - // PathToSCLangConfig is the path to SuperCollider sclang service config file - PathToSCLangConfig = "/tmp/default/sclang" - - // PathToSuperColliderConfig is the path to SuperCollider service config file - PathToSuperColliderConfig = "/tmp/default/supercollider" - - // PathToSuperColliderStartupFile is the path to SuperCollider startup file - PathToSuperColliderStartupFile = "/tmp/jacktrip.scd" - - // PathToAvahiServiceFile is the path to the avahi service file for jacktrip-agent - PathToAvahiServiceFile = "/tmp/avahi/services/jacktrip-agent.service" - - // JackDeviceConfigTemplate is the template used to generate /tmp/default/jack file on raspberry pi devices - JackDeviceConfigTemplate = "JACK_OPTS=-dalsa -dhw:%s --rate %d --period %d\n" - - // JackServerConfigTemplate is the template used to generate /tmp/default/jack file on audio servers - JackServerConfigTemplate = "JACK_OPTS=-d dummy --rate %d --period %d\n" - - // JackTripDeviceConfigTemplate is the template used to generate /tmp/default/jacktrip file on raspberry pi devices - JackTripDeviceConfigTemplate = "JACKTRIP_OPTS=-t -z -n %d -C %s --peerport %d --bindport %d --clientname hubserver --remotename %s %s\n" - - // JackTripServerConfigTemplate is the template used to generate /tmp/default/jacktrip file on audio servers - JackTripServerConfigTemplate = "JACKTRIP_OPTS=-S -t -z --bindport %d --nojackportsconnect %s\n" - - // JamulusDeviceConfigTemplate is the template used to generate /tmp/default/jamulus file on raspberry pi devices - JamulusDeviceConfigTemplate = "JAMULUS_OPTS=-n -i /tmp/jamulus.ini -c %s:%d\n" - - // SCLangConfigTemplate is the template used to generate /tmp/default/sclang file on audio servers - SCLangConfigTemplate = "SCLANG_OPTS=%s %s\n" - - // SuperColliderConfigTemplate is the template used to generate /tmp/default/supercollider file on audio servers - SuperColliderConfigTemplate = "SC_OPTS=-i %d -o %d -m %d -z %d -a 2570\n" - - // DevicesRedirectURL is a template used to construct UI redirect URL for this device - DevicesRedirectURL = "https://app.jacktrip.org/devices/%s?apiPrefix=%s&apiHash=%s" - - // EC2InstanceIDURL is url using EC2 metadata service that returns the instance-id - // See https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/ec2-instance-metadata.html - EC2InstanceIDURL = "http://169.254.169.254/latest/meta-data/instance-id" - - // PathToMACAddress is the path to ethernet device MAC address, via Linux kernel - PathToMACAddress = "/sys/class/net/eth0/address" - - // PathToAsoundCards is the path to the ALSA card list - PathToAsoundCards = "/proc/asound/cards" - // AgentConfigDir is the directory containing agent config files AgentConfigDir = "/etc/jacktrip" // AgentLibDir is the directory containing additional files used by the agent AgentLibDir = "/var/lib/jacktrip" - - // SecretBytes are used to generate random secret strings - SecretBytes = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" ) -//var log = stdr.New(stdlog.New(os.Stderr, "", stdlog.LstdFlags|stdlog.Lshortfile)).WithName("jacktrip.agent") -var zLogger, _ = zap.NewProduction() -var log = zapr.NewLogger(zLogger).WithName("jacktrip.agent") -var soundDeviceName = "" -var soundDeviceType = "" - -func init() { - // seed random number generator for secret generation - rand.Seed(time.Now().UnixNano()) -} - // main wires everything together and starts up the Agent server func main() { @@ -185,906 +48,3 @@ func main() { log.Info("Exiting") } - -// runOnDevice is used to run jacktrip-agent on a raspberry pi device -func runOnDevice(apiOrigin string) { - log.Info("Running jacktrip-agent in device mode") - - // get sound device name and type - soundDeviceName = getSoundDeviceName() - soundDeviceType = getSoundDeviceType() - log.Info("Detected sound device", "name", soundDeviceName, "type", soundDeviceType) - - // restore alsa card state - alsaStateFile := fmt.Sprintf("%s/asound.%s.state", AgentLibDir, soundDeviceType) - log.Info("Restoring ALSA state", "file", alsaStateFile) - cmd := exec.Command("/usr/sbin/alsactl", "restore", "--file", alsaStateFile) - if err := cmd.Run(); err != nil { - log.Error(err, "Unable to restore ALSA state", "file", alsaStateFile) - } - - // get mac and credentials - mac := getMACAddress() - credentials := getCredentials() - - // setup wait group for multiple routines - var wg sync.WaitGroup - - // start HTTP server to redirect requests - router := mux.NewRouter() - router.PathPrefix("/").Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - handleDeviceRedirect(mac, credentials, w, r) - })).Methods("GET") - wg.Add(1) - go runHTTPServer(&wg, router) - - // update avahi service config and restart daemon - ping := client.AgentPing{ - AgentCredentials: credentials, - MAC: mac, - Version: getPatchVersion(), - Type: soundDeviceType, - } - updateAvahiServiceConfig(ping, "starting") - - // start ping server to send pings and update agent config - wg.Add(1) - go runPingServer(&wg, ping, apiOrigin) - - // wait for everything to complete - wg.Wait() -} - -// runOnServer is used to run jacktrip-agent on an audio cloud server -func runOnServer(apiOrigin string) { - // setup wait group for multiple routines - var wg sync.WaitGroup - - log.Info("Running jacktrip-agent in server mode") - - // start HTTP server to respond to pings - router := mux.NewRouter() - router.HandleFunc("/ping", handlePingRequest).Methods("GET") - wg.Add(1) - go runHTTPServer(&wg, router) - - // get cloud id - cloudID := getCloudID() - - // TODO: get credentials - credentials := client.AgentCredentials{} - - // start ping server to send pings and update agent config - ping := client.AgentPing{ - AgentCredentials: credentials, - CloudID: cloudID, - Version: getPatchVersion(), - } - wg.Add(1) - go runPingServer(&wg, ping, apiOrigin) - - // wait for everything to complete - wg.Wait() -} - -// getCloudID retrieves cloud instance id, via metadata service -func getCloudID() string { - // send request to get instance-id from EC2 metadata - r, err := http.Get(EC2InstanceIDURL) - if err != nil { - log.Error(err, "Failed to retrieve instance-id from metadata service") - panic(err) - } - defer r.Body.Close() - - // check response status - if r.StatusCode != http.StatusOK { - err = errors.New("Failed to retrieve instance-id from metadata service") - log.Error(err, "Bad status code", "status", r.StatusCode) - panic(err) - } - - // decode config from response - bodyBytes, err := ioutil.ReadAll(r.Body) - cloudID := string(bodyBytes) - if err != nil || cloudID == "" { - err = errors.New("Failed to retrieve instance-id from metadata service") - log.Error(err, "Empty response") - panic(err) - } - - log.Info("Retrieved instance-id", "id", cloudID) - - return cloudID -} - -// getMACAddress retrieves ethernet device MAC address, via Linux kernel -func getMACAddress() string { - macBytes, err := ioutil.ReadFile(PathToMACAddress) - if err != nil { - log.Error(err, "Unable to retrieve MAC address") - panic(err) - } - - // trip whitespace and convert to lowercase - mac := strings.TrimSpace(string(macBytes)) - mac = strings.ToLower(mac) - - log.Info("Retrieved MAC address", "mac", mac) - return mac -} - -// getPatchVersion retrieves patch version for the device -func getPatchVersion() string { - rawBytes, err := ioutil.ReadFile(fmt.Sprintf("%s/patch", AgentConfigDir)) - if err != nil { - return "" - } - - // trim whitespace - patchVersion := strings.TrimSpace(string(rawBytes)) - - log.Info("Retrieved patch version", "version", patchVersion) - return patchVersion -} - -// getSoundDeviceName retrieves alsa name for the sound device -func getSoundDeviceName() string { - rawBytes, err := ioutil.ReadFile(fmt.Sprintf("%s/devicename", AgentConfigDir)) - if err != nil { - log.Error(err, "Unable to retrieve name of sound device") - panic(err) - } - return strings.TrimSpace(string(rawBytes)) -} - -// getSoundDeviceType retrieves alsa type for the sound device -func getSoundDeviceType() string { - rawBytes, err := ioutil.ReadFile(fmt.Sprintf("%s/devicetype", AgentConfigDir)) - if err != nil { - log.Error(err, "Unable to retrieve type of sound device") - panic(err) - } - return strings.TrimSpace(string(rawBytes)) -} - -// getCredentials retrieves jacktrip agent credentials from system config file. -// If config does not exist, it will generate and save new credentials to config file. -func getCredentials() client.AgentCredentials { - - rawBytes, err := ioutil.ReadFile(fmt.Sprintf("%s/credentials", AgentConfigDir)) - if err != nil { - log.Error(err, "Failed to read credentials") - panic(err) - } - - splits := bytes.Split(bytes.TrimSpace(rawBytes), []byte(".")) - if len(splits) != 2 || len(splits[0]) < 1 || len(splits[1]) < 1 { - log.Error(err, "Failed to parse credentials") - panic(err) - } - - return client.AgentCredentials{ - APIPrefix: string(splits[0]), - APISecret: string(splits[1]), - } -} - -// runPingServer sends pings to service and manages config updates -func runPingServer(wg *sync.WaitGroup, ping client.AgentPing, apiOrigin string) { - defer wg.Done() - - log.Info("Starting agent ping server") - - lastStatus := "starting" - config := client.AgentConfig{} - lastConfig := config - getPingStats(&ping, nil) - - for { - // update and encode ping content - pingBytes, err := json.Marshal(ping) - if err != nil { - log.Error(err, "Failed to marshal agent ping request") - if ping.CloudID == "" && lastStatus != "error" { - updateAvahiServiceConfig(ping, "error") - lastStatus = "error" - } - panic(err) - } - - // send ping request - r, err := http.Post(fmt.Sprintf("%s%s", apiOrigin, AgentPingURL), "application/json", bytes.NewReader(pingBytes)) - if err != nil { - log.Error(err, "Failed to send agent ping request") - if ping.CloudID == "" && lastStatus != "error" { - updateAvahiServiceConfig(ping, "error") - lastStatus = "error" - } - time.Sleep(time.Second * AgentPingInterval) - continue - } - defer r.Body.Close() - - // check response status - if r.StatusCode != http.StatusOK { - log.Info("Bad response from agent ping", "status", r.StatusCode) - if ping.CloudID == "" && lastStatus != "error" { - updateAvahiServiceConfig(ping, "error") - lastStatus = "error" - } - time.Sleep(time.Second * AgentPingInterval) - continue - } - - // decode config from response - decoder := json.NewDecoder(r.Body) - if err := decoder.Decode(&config); err != nil { - log.Error(err, "Failed to unmarshal agent ping response") - if ping.CloudID == "" && lastStatus != "error" { - updateAvahiServiceConfig(ping, "error") - lastStatus = "error" - } - time.Sleep(time.Second * AgentPingInterval) - continue - } - - // check if config has changed - if config != lastConfig { - log.Info("Config updated", "value", config) - - // update ALSA card settings - if ping.CloudID == "" && config.ALSAConfig != lastConfig.ALSAConfig { - updateALSASettings(config) - } - - // check if ALSA card settings was the only change - lastConfig.ALSAConfig = config.ALSAConfig - if config != lastConfig { - - // check if supercollider code was the only change - lastConfig.MixBranch = config.MixBranch - lastConfig.MixCode = config.MixCode - if config == lastConfig { - - // only the supercollider code needs updated - // update configs and restart sclang service to minimize disruption - updateSuperColliderConfigs(config) - err = restartService(SCLangServiceName) - if err != nil { - log.Error(err, "Unable to restart service", "name", SCLangServiceName) - } - - } else { - // more changes required -> reset everything - - // update managed config files - updateServiceConfigs(config, strings.Replace(ping.MAC, ":", "", -1), ping.CloudID != "") - - // shutdown or restart managed services - restartAllServices(config, ping.CloudID != "") - } - } - - lastConfig = config - } - - // update device status in avahi service config, if necessary - if ping.CloudID == "" { - status := "not connected" - if config.Enabled { - status = "connected" - } - if lastStatus != status { - updateAvahiServiceConfig(ping, status) - lastStatus = status - } - } - - if ping.CloudID == "" && config.Enabled && config.Host != "" { - // ping server instead of sleeping - pinger, err := goping.NewPinger(config.Host) - if err == nil { - log.V(2).Info("Pinging server", "host", config.Host) - pinger.Count = AgentPingInterval * 10 - pinger.Interval = time.Millisecond * 100 - pinger.Timeout = time.Second * AgentPingInterval - pinger.Run() - log.V(1).Info("Done pinging server", "host", config.Host, "stats", *pinger.Statistics()) - getPingStats(&ping, pinger.Statistics()) - } else { - log.Error(err, "Failed to create pinger") - // sleep in between pings - time.Sleep(time.Second * AgentPingInterval) - getPingStats(&ping, nil) - } - } else { - // sleep in between pings - time.Sleep(time.Second * AgentPingInterval) - getPingStats(&ping, nil) - } - } -} - -// getPingStats updates and AgentPing message with go-ping Pinger stats -func getPingStats(ping *client.AgentPing, stats *goping.Statistics) { - ping.StatsUpdatedAt = time.Now() - - if stats == nil { - ping.PacketsRecv = 0 - ping.PacketsSent = 0 - ping.MinRtt = 0 - ping.MaxRtt = 0 - ping.AvgRtt = 0 - ping.StdDevRtt = 0 - return - } - - ping.PacketsRecv = stats.PacketsRecv - ping.PacketsSent = stats.PacketsSent - ping.MinRtt = stats.MinRtt - ping.MaxRtt = stats.MaxRtt - ping.AvgRtt = stats.AvgRtt - ping.StdDevRtt = stats.StdDevRtt -} - -// updateALSASettings is used to update the settings for an ALSA sound card -func updateALSASettings(config client.AgentConfig) { - switch soundDeviceType { - case "snd_rpi_hifiberry_dacplusadc": - fallthrough - case "snd_rpi_hifiberry_dacplusadcpro": - updateALSASettingsHiFiBerry(config) - break - case "audioinjector-pi-soundcard": - updateALSASettingsAudioInjector(config) - break - case "USB Audio Device": - updateALSASettingsUSBAudioDevice(config) - break - case "USB PnP Sound Device": - updateALSASettingsUSBPnPSoundDevice(config) - break - default: - log.Info("No ALSA alsa controls for sound device", "type", soundDeviceType) - } -} - -// updateALSASettings is used to update the settings for a HiFiBerry sound card -func updateALSASettingsHiFiBerry(config client.AgentConfig) { - var v int - amixerDevice := fmt.Sprintf("hw:%s", soundDeviceName) - - // ignore capture boost - /* - if config.CaptureBoost { - v = 104 - } else { - v = 0 - } - cmd := exec.Command("/usr/bin/amixer", "-D", amixerDevice, "cset", "name='PGA Gain Left'", "--", fmt.Sprintf("%d", v)) - if err := cmd.Run(); err != nil { - log.Error(err, "Unable to update 'PGA Gain Left'", "value", v) - } else { - log.Info("Updated 'PGA Gain Left'", "value", v) - } - cmd = exec.Command("/usr/bin/amixer", "-D", amixerDevice, "cset", "name='PGA Gain Right'", "--", fmt.Sprintf("%d", v)) - if err := cmd.Run(); err != nil { - log.Error(err, "Unable to update 'PGA Gain Right'", "value", v) - } else { - log.Info("Updated 'PGA Gain Right'", "value", v) - } - */ - - // set capture volume - // note: 'PGA Gain Left' and 'PGA Gain Right' appear to map directly to 'ADC Capture Volume' left & right - v = int(config.CaptureVolume * 104 / 100) - cmd := exec.Command("/usr/bin/amixer", "-D", amixerDevice, "cset", "name='ADC Capture Volume'", "--", fmt.Sprintf("%d,%d", v, v)) - if err := cmd.Run(); err != nil { - log.Error(err, "Unable to update 'ADC Capture Volume'", "value", v) - } else { - log.Info("Updated 'ADC Capture Volume'", "value", v) - } - - // set playback boost - if config.PlaybackBoost { - v = 1 - } else { - v = 0 - } - cmd = exec.Command("/usr/bin/amixer", "-D", amixerDevice, "cset", "name='Analogue Playback Volume'", "--", fmt.Sprintf("%d,%d", v, v)) - if err := cmd.Run(); err != nil { - log.Error(err, "Unable to update 'Analogue Playback Volume'", "value", v) - } else { - log.Info("Updated 'Analogue Playback Volume'", "value", v) - } - - // set playback volume - v = int(config.PlaybackVolume * 207 / 100) - cmd = exec.Command("/usr/bin/amixer", "-D", amixerDevice, "cset", "name='Digital Playback Volume'", "--", fmt.Sprintf("%d,%d", v, v)) - if err := cmd.Run(); err != nil { - log.Error(err, "Unable to update 'Digital Playback Volume' to %d: %s", "value", v) - } else { - log.Info("Updated 'Digital Playback Volume'", "value", v) - } -} - -// updateALSASettingsAudioInjector is used to update the settings for a Audio Injector Stereo sound card -func updateALSASettingsAudioInjector(config client.AgentConfig) { - var v int - amixerDevice := fmt.Sprintf("hw:%s", soundDeviceName) - - // enable built in mic with boost, if set - if config.CaptureBoost { - v = 1 - } else { - v = 0 - } - cmd := exec.Command("/usr/bin/amixer", "-D", amixerDevice, "cset", "name='Mic Boost Volume'", "--", fmt.Sprintf("%d", v)) - if err := cmd.Run(); err != nil { - log.Error(err, "Unable to update 'Mic Boost Volume'", "value", v) - } else { - log.Info("Updated 'Mic Boost Volume'", "value", v) - } - cmd = exec.Command("/usr/bin/amixer", "-D", amixerDevice, "cset", "name='Input Mux'", "--", fmt.Sprintf("%d", v)) - if err := cmd.Run(); err != nil { - log.Error(err, "Unable to update 'Input Mux'", "value", v) - } else { - log.Info("Updated 'Input Mux'", "value", v) - } - - // set capture volume - v = int(config.CaptureVolume * 31 / 100) - cmd = exec.Command("/usr/bin/amixer", "-D", amixerDevice, "cset", "name='Capture Volume'", "--", fmt.Sprintf("%d", v)) - if err := cmd.Run(); err != nil { - log.Error(err, "Unable to update 'Capture Volume'", "value", v) - } else { - log.Info("Updated 'Capture Volume'", "value", v) - } - - // set playback volume - v = int(config.PlaybackVolume * 127 / 100) - cmd = exec.Command("/usr/bin/amixer", "-D", amixerDevice, "cset", "name='Master Playback Volume'", "--", fmt.Sprintf("%d,%d", v, v)) - if err := cmd.Run(); err != nil { - log.Error(err, "Unable to update 'Master Playback Volume' to %d: %s", "value", v) - } else { - log.Info("Updated 'Master Playback Volume'", "value", v) - } -} - -// updateALSASettingsUSBAudioDevice is used to update the settings for a USB sound card -func updateALSASettingsUSBAudioDevice(config client.AgentConfig) { - var v int - amixerDevice := fmt.Sprintf("hw:%s", soundDeviceName) - - // set capture volume - v = int(config.CaptureVolume * 35 / 100) - cmd := exec.Command("/usr/bin/amixer", "-D", amixerDevice, "cset", "name='Mic Capture Volume'", "--", fmt.Sprintf("%d", v)) - if err := cmd.Run(); err != nil { - log.Error(err, "Unable to update 'Mic Capture Volume'", "value", v) - } else { - log.Info("Updated 'Mic Capture Volume'", "value", v) - } - - // set playback volume - v = int(config.PlaybackVolume * 37 / 100) - cmd = exec.Command("/usr/bin/amixer", "-D", amixerDevice, "cset", "name='Speaker Playback Volume'", "--", fmt.Sprintf("%d,%d", v, v)) - if err := cmd.Run(); err != nil { - log.Error(err, "Unable to update 'Speaker Playback Volume' to %d: %s", "value", v) - } else { - log.Info("Updated 'Speaker Playback Volume'", "value", v) - } -} - -// updateALSASettingsUSBPnPSoundDevice is used to update the settings for a USB sound card -func updateALSASettingsUSBPnPSoundDevice(config client.AgentConfig) { - var v int - amixerDevice := fmt.Sprintf("hw:%s", soundDeviceName) - - // set capture volume - v = int(config.CaptureVolume * 16 / 100) - cmd := exec.Command("/usr/bin/amixer", "-D", amixerDevice, "cset", "name='Mic Capture Volume'", "--", fmt.Sprintf("%d", v)) - if err := cmd.Run(); err != nil { - log.Error(err, "Unable to update 'Mic Capture Volume'", "value", v) - } else { - log.Info("Updated 'Mic Capture Volume'", "value", v) - } - - // set playback volume - v = int(config.PlaybackVolume * 151 / 100) - cmd = exec.Command("/usr/bin/amixer", "-D", amixerDevice, "cset", "name='Speaker Playback Volume'", "--", fmt.Sprintf("%d,%d", v, v)) - if err := cmd.Run(); err != nil { - log.Error(err, "Unable to update 'Speaker Playback Volume' to %d: %s", "value", v) - } else { - log.Info("Updated 'Speaker Playback Volume'", "value", v) - } -} - -// updateSuperColliderConfigs is used to update SuperCollider config files on managed audio servers -func updateSuperColliderConfigs(config client.AgentConfig) { - // write SuperCollider (server) config file - maxClients := runtime.NumCPU() * MaxClientsPerProcessor - numInputChannels := maxClients * 2 - numOutputChannels := maxClients * 2 - scMemorySize := 8192 - if maxClients > 50 { - scMemorySize = 16384 - } - scBufSize := 64 - if config.Period < 64 { - scBufSize = config.Period - } - scConfig := fmt.Sprintf(SuperColliderConfigTemplate, numInputChannels, numOutputChannels, scMemorySize, scBufSize) - err := ioutil.WriteFile(PathToSuperColliderConfig, []byte(scConfig), 0644) - if err != nil { - log.Error(err, "Failed to save SuperCollider config", "path", PathToSuperColliderConfig) - } - - // write SuperCollider (sclang) config file - sclangConfig := fmt.Sprintf(SCLangConfigTemplate, config.MixBranch, PathToSuperColliderStartupFile) - err = ioutil.WriteFile(PathToSCLangConfig, []byte(sclangConfig), 0644) - if err != nil { - log.Error(err, "Failed to save sclang config", "path", PathToSCLangConfig) - } - - // write SuperCollider startup file - scStartup := fmt.Sprintf(`~maxClients = %d; -~inputChannelsPerClient = 2; -~outputChannelsPerClient = 2; -%s`, maxClients, config.MixCode) - err = ioutil.WriteFile(PathToSuperColliderStartupFile, []byte(scStartup), 0644) - if err != nil { - log.Error(err, "Failed to save SuperCollider startup file", "path", PathToSuperColliderStartupFile) - } -} - -// updateServiceConfigs is used to update config for managed systemd services -func updateServiceConfigs(config client.AgentConfig, remoteName string, isServer bool) { - - // assume auto queue unless > 0 - jackTripExtraOpts := "-q auto" - if config.QueueBuffer > 0 { - jackTripExtraOpts = fmt.Sprintf("-q %d", config.QueueBuffer) - } - - // create config opts from templates - var jackConfig, jackTripConfig string - if isServer { - jackConfig = fmt.Sprintf(JackServerConfigTemplate, config.SampleRate, config.Period) - jackTripConfig = fmt.Sprintf(JackTripServerConfigTemplate, config.Port, jackTripExtraOpts) - } else { - updateJamulusIni(config) - - jackConfig = fmt.Sprintf(JackDeviceConfigTemplate, soundDeviceName, config.SampleRate, config.Period) - - // configure limiter - if config.Limiter { - jackTripExtraOpts = fmt.Sprintf("%s -Oio", jackTripExtraOpts) - } - - // configure effects - jackTripEffects := "" - if config.Compressor { - jackTripEffects = "o:c" - } - if config.Reverb > 0 { - reverbFloat := float32(config.Reverb) / 100 - jackTripEffects = fmt.Sprintf("%s i:f(%f)", jackTripEffects, reverbFloat) - } - if jackTripEffects != "" { - jackTripExtraOpts = fmt.Sprintf("%s -f \"%s\"", jackTripExtraOpts, strings.TrimSpace(jackTripEffects)) - } - - // check if loopback - //hubpatch := 2 - //if config.LoopBack { - // hubpatch = 4 - //} - - // check if stereo - channels := 1 - if config.Stereo { - channels = 2 - } - - jackTripConfig = fmt.Sprintf(JackTripDeviceConfigTemplate, channels, config.Host, config.Port, config.DevicePort, remoteName, strings.TrimSpace(jackTripExtraOpts)) - } - - // ensure config directory exists - err := os.MkdirAll("/tmp/default", 0755) - if err != nil { - log.Error(err, "Failed to create directory", "path", "/tmp/default") - panic(err) - } - - // write jack config file - err = ioutil.WriteFile(PathToJackConfig, []byte(jackConfig), 0644) - if err != nil { - log.Error(err, "Failed to save Jack config", "path", PathToJackConfig) - panic(err) - } - - // write JackTrip config file - err = ioutil.WriteFile(PathToJackTripConfig, []byte(jackTripConfig), 0644) - if err != nil { - log.Error(err, "Failed to save JackTrip config", "path", PathToJackTripConfig) - } - - // write Jamulus config file - jamulusConfig := fmt.Sprintf(JamulusDeviceConfigTemplate, config.Host, config.Port) - err = ioutil.WriteFile(PathToJamulusConfig, []byte(jamulusConfig), 0644) - if err != nil { - log.Error(err, "Failed to save Jamulus config", "path", PathToJamulusConfig) - } - - if isServer { - // update SuperCollider config files - updateSuperColliderConfigs(config) - } -} - -// updateJamulusIni writes a new /tmp/jamulus.ini file using template at /var/lib/jacktrip/jamulus.ini -func updateJamulusIni(config client.AgentConfig) { - srcFileName := "/var/lib/jacktrip/jamulus.ini" - srcFile, err := os.Open(srcFileName) - if err != nil { - log.Error(err, "Failed to open file for reading", "path", srcFileName) - } - defer srcFile.Close() - - dstFileName := "/tmp/jamulus.ini" - dstFile, err := os.OpenFile(dstFileName, os.O_CREATE|os.O_WRONLY, 0644) - if err != nil { - log.Error(err, "Failed to open file for writing", "path", dstFileName) - } - defer dstFile.Close() - - quality := 2 - if config.Quality == 0 { - quality = 0 - } - - writer := bufio.NewWriter(dstFile) - scanner := bufio.NewScanner(srcFile) - audioQualityRx := regexp.MustCompile(`.*.*.*`) - - writeToFile := func() { - line := scanner.Text() - if audioQualityRx.MatchString(line) { - line = fmt.Sprintf("%d", quality) - } - _, err = writer.WriteString(line + "\n") - if err != nil { - log.Error(err, "Error writing to file", "path", dstFileName) - } - } - - for scanner.Scan() { - writeToFile() - } - - if err := scanner.Err(); err != nil { - log.Error(err, "Error reading file", "path", srcFileName) - } - - writeToFile() - writer.Flush() -} - -// updateAvahiServiceConfig generates a new /etc/avahi/services/jacktrip-agent.service file -func updateAvahiServiceConfig(ping client.AgentPing, status string) { - // ensure config directory exists - err := os.MkdirAll("/tmp/avahi/services", 0755) - if err != nil { - log.Error(err, "Failed to create directory", "path", "/tmp/avahi/services") - return - } - - apiHash := client.GetAPIHash(ping.APISecret) - avahiServiceConfig := fmt.Sprintf(` - - - JackTrip Agent on %%h - - _http._tcp - 80 - status=%s - version=%s - mac=%s - apiHash=%s - - -`, status, ping.Version, ping.MAC, apiHash) - - err = ioutil.WriteFile(PathToAvahiServiceFile, []byte(avahiServiceConfig), 0644) - if err != nil { - log.Error(err, "Failed to save avahi service config", "path", PathToAvahiServiceFile) - return - } -} - -// restartAllServices is used to restart all of the managed systemd services -func restartAllServices(config client.AgentConfig, isServer bool) { - // create dbus connection to manage systemd units - conn, err := dbus.New() - if err != nil { - log.Error(err, "Failed to connect to dbus") - panic(err) - } - defer conn.Close() - - // stop any managed services that are active - units, err := conn.ListUnitsByNames([]string{JackServiceName, SuperColliderServiceName, SCLangServiceName, - JackAutoconnectServiceName, JackPlumbingServiceName, JackTripReceiveServiceName, JackTripSendServiceName, - JackTripServiceName, JamulusServiceName, JamulusServerServiceName, JamulusBridgeServiceName}) - if err != nil { - log.Error(err, "Failed to get status of managed services") - panic(err) - } - for _, u := range units { - err = stopService(conn, u) - if err != nil { - log.Error(err, "Unable to stop service") - panic(err) - } - } - - // don't restart if server is not active - if !config.Enabled { - return - } - - // determine which services to start - var servicesToStart []string - switch config.Type { - case client.JackTrip: - servicesToStart = []string{JackServiceName, JackTripServiceName} - if isServer { - if runtime.NumCPU() > 36 { - servicesToStart = append(servicesToStart, JackTripReceiveServiceName, JackTripSendServiceName, JackPlumbingServiceName) - } else { - servicesToStart = append(servicesToStart, SuperColliderServiceName, SCLangServiceName, JackAutoconnectServiceName) - } - } - case client.Jamulus: - if isServer { - servicesToStart = []string{JackServiceName, JamulusServerServiceName} - } else { - servicesToStart = []string{JackServiceName, JamulusServiceName} - } - case client.JackTripJamulus: - if isServer { - servicesToStart = []string{JackServiceName, JackTripServiceName} - servicesToStart = append(servicesToStart, JamulusServerServiceName, JamulusBridgeServiceName) - if runtime.NumCPU() > 36 { - servicesToStart = append(servicesToStart, JackTripReceiveServiceName, JackTripSendServiceName, JackPlumbingServiceName) - } else { - servicesToStart = append(servicesToStart, SuperColliderServiceName, SCLangServiceName, JackAutoconnectServiceName) - } - } else { - switch config.Quality { - case 0: - servicesToStart = []string{JackServiceName, JamulusServiceName} - case 1: - servicesToStart = []string{JackServiceName, JamulusServiceName} - case 2: - servicesToStart = []string{JackServiceName, JackTripServiceName} - } - } - } - - // start managed services - for _, serviceName := range servicesToStart { - err = startService(conn, serviceName) - if err != nil { - log.Error(err, "Unable to start service", "name", serviceName) - panic(err) - } - } -} - -// stopService is used to stop a managed systemd service -func stopService(conn *dbus.Conn, u dbus.UnitStatus) error { - if u.ActiveState == "inactive" { - return nil - } - - log.Info("Stopping managed service", "service", u.Name) - - reschan := make(chan string) - _, err := conn.StopUnit(u.Name, "replace", reschan) - if err != nil { - return fmt.Errorf("Failed to stop %s: job status=%s", u.Name, err.Error()) - } - - jobStatus := <-reschan - if jobStatus != "done" { - return fmt.Errorf("Failed to stop %s: job status=%s", u.Name, jobStatus) - } - - return nil -} - -// startService is used to start a managed systemd service -func startService(conn *dbus.Conn, name string) error { - log.Info("Starting managed service", "service", name) - - reschan := make(chan string) - _, err := conn.StartUnit(name, "replace", reschan) - if err != nil { - return fmt.Errorf("Failed to start %s: job status=%s", name, err.Error()) - } - - jobStatus := <-reschan - if jobStatus != "done" { - return fmt.Errorf("Failed to start %s: job status=%s", name, jobStatus) - } - - return nil -} - -// startService is used to restart a managed systemd service -func restartService(name string) error { - log.Info("Restarting managed service", "service", name) - - // create dbus connection to manage systemd units - conn, err := dbus.New() - if err != nil { - return errors.New("Failed to connect to dbus") - } - defer conn.Close() - - reschan := make(chan string) - _, err = conn.RestartUnit(name, "replace", reschan) - if err != nil { - return fmt.Errorf("Failed to restart %s: job status=%s", name, err.Error()) - } - - jobStatus := <-reschan - if jobStatus != "done" { - return fmt.Errorf("Failed to restart %s: job status=%s", name, jobStatus) - } - - return nil -} - -// runHTTPServer runs the agent's HTTP server -func runHTTPServer(wg *sync.WaitGroup, router *mux.Router) error { - defer wg.Done() - log.Info("Starting agent HTTP server") - err := http.ListenAndServe(LocalHTTPServer, router) - if err != nil { - log.Error(err, "HTTP server error") - } - return err -} - -// handleDeviceRedirect redirects all requests to devices in jacktrip web application -func handleDeviceRedirect(mac string, credentials client.AgentCredentials, w http.ResponseWriter, r *http.Request) { - apiHash := client.GetAPIHash(credentials.APISecret) - w.Header().Set("Location", fmt.Sprintf(DevicesRedirectURL, mac, credentials.APIPrefix, apiHash)) - w.WriteHeader(http.StatusSeeOther) -} - -// handlePingRequest upgrades ping request to a websocket responder -func handlePingRequest(w http.ResponseWriter, r *http.Request) { - upgrader := websocket.Upgrader{} - c, err := upgrader.Upgrade(w, r, nil) - if err != nil { - log.Error(err, "Unable to upgrade to websocket") - return - } - defer c.Close() - for { - mt, message, err := c.ReadMessage() - if err != nil { - log.Error(err, "Unable to read websocket message") - break - } - err = c.WriteMessage(mt, message) - if err != nil { - log.Error(err, "Unable to write websocket message") - break - } - } -} diff --git a/cmd/ping.go b/cmd/ping.go new file mode 100644 index 0000000..cd70b17 --- /dev/null +++ b/cmd/ping.go @@ -0,0 +1,69 @@ +// Copyright 2020-2021 JackTrip Labs, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + + "github.com/jacktrip/jacktrip-agent/pkg/client" +) + +const ( + // AgentPingURL is the URL used to POST agent pings + AgentPingURL = "/agents/ping" + + // AgentPingInterval is number of seconds in between agent ping requests + AgentPingInterval = 5 +) + +var lastConfig client.AgentConfig + +// sendPing sends ping to service to retrieve config +func sendPing(ping client.AgentPing, apiOrigin string) (client.AgentConfig, error) { + var config client.AgentConfig + + // update and encode ping content + pingBytes, err := json.Marshal(ping) + if err != nil { + log.Error(err, "Failed to marshal agent ping request") + return config, err + } + + // send ping request + r, err := http.Post(fmt.Sprintf("%s%s", apiOrigin, AgentPingURL), "application/json", bytes.NewReader(pingBytes)) + if err != nil { + log.Error(err, "Failed to send agent ping request") + return config, err + } + defer r.Body.Close() + + // check response status + if r.StatusCode != http.StatusOK { + log.Info("Bad response from agent ping", "status", r.StatusCode) + return config, err + } + + // decode config from response + decoder := json.NewDecoder(r.Body) + if err := decoder.Decode(&config); err != nil { + log.Error(err, "Failed to unmarshal agent ping response") + return config, err + } + + return config, nil +} diff --git a/cmd/server.go b/cmd/server.go new file mode 100644 index 0000000..fa91773 --- /dev/null +++ b/cmd/server.go @@ -0,0 +1,258 @@ +// Copyright 2020-2021 JackTrip Labs, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "errors" + "fmt" + "io/ioutil" + "net/http" + "runtime" + "sync" + "time" + + "github.com/gorilla/mux" + + "github.com/jacktrip/jacktrip-agent/pkg/client" +) + +const ( + // MaxClientsPerProcessor is the number of JackTrip clients to support per logical processor + MaxClientsPerProcessor = 6 // add 1 (20%) for Jamulus bridge, etc + + // SCSynthServiceName is the name of the systemd service for the SuperCollider scsynth server + SCSynthServiceName = "scsynth.service" + + // SupernovaServiceName is the name of the systemd service for the SuperCollider supernova server + SupernovaServiceName = "supernova.service" + + // SCLangServiceName is the name of the systemd service for the SuperCollider language runtime + SCLangServiceName = "sclang.service" + + // JackAutoconnectServiceName is the name of the systemd service for connecting jack clients + JackAutoconnectServiceName = "jack-autoconnect.service" + + // JamulusServerServiceName is the name of the systemd service for the Jamulus server + JamulusServerServiceName = "jamulus-server.service" + + // JamulusBridgeServiceName is the name of the systemd service for the Jamulus -> JackTrip bridge + JamulusBridgeServiceName = "jamulus-bridge.service" + + // PathToSCLangConfig is the path to SuperCollider sclang service config file + PathToSCLangConfig = "/tmp/default/sclang" + + // PathToSuperColliderConfig is the path to SuperCollider service config file + PathToSuperColliderConfig = "/tmp/default/supercollider" + + // PathToSuperColliderStartupFile is the path to SuperCollider startup file + PathToSuperColliderStartupFile = "/tmp/jacktrip.scd" + + // JackServerConfigTemplate is the template used to generate /tmp/default/jack file on audio servers + JackServerConfigTemplate = "JACK_OPTS=-d dummy --rate %d --period %d\n" + + // JackTripServerConfigTemplate is the template used to generate /tmp/default/jacktrip file on audio servers + JackTripServerConfigTemplate = "JACKTRIP_OPTS=-S -t -z --bindport %d --nojackportsconnect --broadcast 1024 %s\n" + + // SCLangConfigTemplate is the template used to generate /tmp/default/sclang file on audio servers + SCLangConfigTemplate = "SCLANG_OPTS=%s %s\n" + + // SuperColliderConfigTemplate is the template used to generate /tmp/default/supercollider file on audio servers + SuperColliderConfigTemplate = "SC_OPTS=-i %d -o %d -a %d -m %d -z %d -n 4096 -d 2048 -w 2048\n" + + // EC2InstanceIDURL is url using EC2 metadata service that returns the instance-id + // See https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/ec2-instance-metadata.html + EC2InstanceIDURL = "http://169.254.169.254/latest/meta-data/instance-id" + + // GCloudInstanceIDURL is url using Google Cloud metadata service that returns the instance name + // See https://cloud.google.com/compute/docs/storing-retrieving-metadata + GCloudInstanceIDURL = "http://metadata.google.internal/computeMetadata/v1/instance/name" + + // AzureInstanceIDURL is url using Azure metadata service that returns the instance name + // See https://docs.microsoft.com/en-us/azure/virtual-machines/linux/instance-metadata-service?tabs=linux + AzureInstanceIDURL = "http://169.254.169.254/metadata/instance/compute/name?api-version=2017-08-01&format=text" +) + +// runOnServer is used to run jacktrip-agent on an audio cloud server +func runOnServer(apiOrigin string) { + // setup wait group for multiple routines + var wg sync.WaitGroup + + log.Info("Running jacktrip-agent in server mode") + + // start HTTP server to respond to pings + router := mux.NewRouter() + router.HandleFunc("/ping", handlePingRequest).Methods("GET") + router.PathPrefix("/").Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + OptionsGetOnly(w, r) + })).Methods("OPTIONS") + wg.Add(1) + go runHTTPServer(&wg, router, ":4400") + + // get cloud id + cloudID := getCloudID() + + // TODO: get credentials + credentials := client.AgentCredentials{} + + // start ping server to send pings and update agent config + ping := client.AgentPing{ + AgentCredentials: credentials, + CloudID: cloudID, + Version: getPatchVersion(), + } + wg.Add(1) + go runServerPinger(&wg, ping, apiOrigin) + + // wait for everything to complete + wg.Wait() +} + +// runServerPinger sends pings to service and manages config updates +func runServerPinger(wg *sync.WaitGroup, ping client.AgentPing, apiOrigin string) { + defer wg.Done() + + log.Info("Starting agent ping server") + + for { + config, err := sendPing(ping, apiOrigin) + if err != nil { + panic(err) + } + + // check if config has changed + if config != lastConfig { + log.Info("Config updated", "value", config) + handleServerUpdate(config) + } + + // sleep in between pings + time.Sleep(time.Second * AgentPingInterval) + } +} + +// handleServerUpdate handles updates to server configuratiosn +func handleServerUpdate(config client.AgentConfig) { + + // check if supercollider code was the only change + lastConfig.MixBranch = config.MixBranch + lastConfig.MixCode = config.MixCode + if config == lastConfig { + + // only the supercollider code needs updated + // update configs and restart sclang service to minimize disruption + updateSuperColliderConfigs(config) + err := restartService(SCLangServiceName) + if err != nil { + log.Error(err, "Unable to restart service", "name", SCLangServiceName) + } + + } else { + // more changes required -> reset everything + + // update managed config files + updateServiceConfigs(config, "", true) + + // shutdown or restart managed services + restartAllServices(config, true) + } + + lastConfig = config +} + +// getCloudID retrieves cloud instance id, via metadata service +func getCloudID() string { + // send request to get instance-id from EC2 metadata + r, err := http.Get(EC2InstanceIDURL) + if err != nil || r.StatusCode != http.StatusOK { + // try again using Google Cloud metadata + client := &http.Client{} + req, _ := http.NewRequest("GET", GCloudInstanceIDURL, nil) + req.Header.Set("Metadata-Flavor", "Google") + r, err = client.Do(req) + if err != nil || r.StatusCode != http.StatusOK { + // try again using Azure metadata + req, _ = http.NewRequest("GET", AzureInstanceIDURL, nil) + req.Header.Set("Metadata", "true") + r, err = client.Do(req) + if err != nil || r.StatusCode != http.StatusOK { + log.Error(err, "Failed to retrieve instance-id from metadata service") + panic(err) + } + } + } + defer r.Body.Close() + + // decode config from response + bodyBytes, err := ioutil.ReadAll(r.Body) + cloudID := string(bodyBytes) + if err != nil || cloudID == "" { + err = errors.New("failed to retrieve instance-id from metadata service") + log.Error(err, "Empty response") + panic(err) + } + + log.Info("Retrieved instance-id", "id", cloudID) + + return cloudID +} + +// updateSuperColliderConfigs is used to update SuperCollider config files on managed audio servers +func updateSuperColliderConfigs(config client.AgentConfig) { + // write SuperCollider (server) config file + maxClients := runtime.NumCPU() * MaxClientsPerProcessor + numInputChannels := maxClients * 2 + numOutputChannels := maxClients * 2 + audioBusses := (numInputChannels + numOutputChannels) * 2 + + // bump memory for larger systems + scMemorySize := 65536 + if maxClients > 50 { + scMemorySize = 262144 + } + + // lower bufsize if jack is lower + scBufSize := 64 + if config.Period < 64 { + scBufSize = config.Period + } + + // create service config using template + scConfig := fmt.Sprintf(SuperColliderConfigTemplate, + numInputChannels, numOutputChannels, audioBusses, + scMemorySize, scBufSize) + + // write SuperCollider (supercollider) service config file + err := ioutil.WriteFile(PathToSuperColliderConfig, []byte(scConfig), 0644) + if err != nil { + log.Error(err, "Failed to save SuperCollider config", "path", PathToSuperColliderConfig) + } + + // write SuperCollider (sclang) service config file + sclangConfig := fmt.Sprintf(SCLangConfigTemplate, config.MixBranch, PathToSuperColliderStartupFile) + err = ioutil.WriteFile(PathToSCLangConfig, []byte(sclangConfig), 0644) + if err != nil { + log.Error(err, "Failed to save sclang config", "path", PathToSCLangConfig) + } + + // write SuperCollider startup file + scStartup := fmt.Sprintf(`~maxClients = %d; +~inputChannelsPerClient = 2; +~outputChannelsPerClient = 2; +%s`, maxClients, config.MixCode) + err = ioutil.WriteFile(PathToSuperColliderStartupFile, []byte(scStartup), 0644) + if err != nil { + log.Error(err, "Failed to save SuperCollider startup file", "path", PathToSuperColliderStartupFile) + } +} diff --git a/cmd/services.go b/cmd/services.go new file mode 100644 index 0000000..ac0e043 --- /dev/null +++ b/cmd/services.go @@ -0,0 +1,319 @@ +// Copyright 2020-2021 JackTrip Labs, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "bufio" + "errors" + "fmt" + "io/ioutil" + "os" + "regexp" + "strings" + + "github.com/coreos/go-systemd/v22/dbus" + + "github.com/jacktrip/jacktrip-agent/pkg/client" +) + +const ( + // JackServiceName is the name of the systemd service for Jack + JackServiceName = "jack.service" + + // JackTripServiceName is the name of the systemd service for JackTrip + JackTripServiceName = "jacktrip.service" + + // JamulusServiceName is the name of the systemd service for Jamulus client on RPI devices + JamulusServiceName = "jamulus.service" + + // PathToJackConfig is the path to Jack service config file + PathToJackConfig = "/tmp/default/jack" + + // PathToJackTripConfig is the path to JackTrip service config file + PathToJackTripConfig = "/tmp/default/jacktrip" + + // PathToJamulusConfig is the path to Jamulus service config file + PathToJamulusConfig = "/tmp/default/jamulus" +) + +// updateServiceConfigs is used to update config for managed systemd services +func updateServiceConfigs(config client.AgentConfig, remoteName string, isServer bool) { + + // assume auto queue unless > 0 + jackTripExtraOpts := "-q auto" + if config.QueueBuffer > 0 { + jackTripExtraOpts = fmt.Sprintf("-q %d", config.QueueBuffer) + } + + // create config opts from templates + var jackConfig, jackTripConfig string + if isServer { + jackConfig = fmt.Sprintf(JackServerConfigTemplate, config.SampleRate, config.Period) + jackTripConfig = fmt.Sprintf(JackTripServerConfigTemplate, config.Port, jackTripExtraOpts) + } else { + updateJamulusIni(config) + + jackConfig = fmt.Sprintf(JackDeviceConfigTemplate, soundDeviceName, config.SampleRate, config.Period) + + // configure limiter + if config.Limiter { + jackTripExtraOpts = fmt.Sprintf("%s -Oio", jackTripExtraOpts) + } + + // configure effects + jackTripEffects := "" + if config.Compressor { + jackTripEffects = "o:c" + } + if config.Reverb > 0 { + reverbFloat := float32(config.Reverb) / 100 + jackTripEffects = fmt.Sprintf("%s i:f(%f)", jackTripEffects, reverbFloat) + } + if jackTripEffects != "" { + jackTripExtraOpts = fmt.Sprintf("%s -f \"%s\"", jackTripExtraOpts, strings.TrimSpace(jackTripEffects)) + } + + // check if loopback + //hubpatch := 2 + //if config.LoopBack { + // hubpatch = 4 + //} + + // check if stereo + channels := 1 + if config.Stereo { + channels = 2 + } + + jackTripConfig = fmt.Sprintf(JackTripDeviceConfigTemplate, channels, config.Host, config.Port, config.DevicePort, remoteName, strings.TrimSpace(jackTripExtraOpts)) + } + + // ensure config directory exists + err := os.MkdirAll("/tmp/default", 0755) + if err != nil { + log.Error(err, "Failed to create directory", "path", "/tmp/default") + panic(err) + } + + // write jack config file + err = ioutil.WriteFile(PathToJackConfig, []byte(jackConfig), 0644) + if err != nil { + log.Error(err, "Failed to save Jack config", "path", PathToJackConfig) + panic(err) + } + + // write JackTrip config file + err = ioutil.WriteFile(PathToJackTripConfig, []byte(jackTripConfig), 0644) + if err != nil { + log.Error(err, "Failed to save JackTrip config", "path", PathToJackTripConfig) + } + + // write Jamulus config file + jamulusConfig := fmt.Sprintf(JamulusDeviceConfigTemplate, config.Host, config.Port) + err = ioutil.WriteFile(PathToJamulusConfig, []byte(jamulusConfig), 0644) + if err != nil { + log.Error(err, "Failed to save Jamulus config", "path", PathToJamulusConfig) + } + + if isServer { + // update SuperCollider config files + updateSuperColliderConfigs(config) + } +} + +// updateJamulusIni writes a new /tmp/jamulus.ini file using template at /var/lib/jacktrip/jamulus.ini +func updateJamulusIni(config client.AgentConfig) { + srcFileName := "/var/lib/jacktrip/jamulus.ini" + srcFile, err := os.Open(srcFileName) + if err != nil { + log.Error(err, "Failed to open file for reading", "path", srcFileName) + } + defer srcFile.Close() + + dstFileName := "/tmp/jamulus.ini" + dstFile, err := os.OpenFile(dstFileName, os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + log.Error(err, "Failed to open file for writing", "path", dstFileName) + } + defer dstFile.Close() + + quality := 2 + if config.Quality == 0 { + quality = 0 + } + + writer := bufio.NewWriter(dstFile) + scanner := bufio.NewScanner(srcFile) + audioQualityRx := regexp.MustCompile(`.*.*.*`) + + writeToFile := func() { + line := scanner.Text() + if audioQualityRx.MatchString(line) { + line = fmt.Sprintf("%d", quality) + } + _, err = writer.WriteString(line + "\n") + if err != nil { + log.Error(err, "Error writing to file", "path", dstFileName) + } + } + + for scanner.Scan() { + writeToFile() + } + + if err := scanner.Err(); err != nil { + log.Error(err, "Error reading file", "path", srcFileName) + } + + writeToFile() + writer.Flush() +} + +// restartAllServices is used to restart all of the managed systemd services +func restartAllServices(config client.AgentConfig, isServer bool) { + // create dbus connection to manage systemd units + conn, err := dbus.New() + if err != nil { + log.Error(err, "Failed to connect to dbus") + panic(err) + } + defer conn.Close() + + // stop any managed services that are active + units, err := conn.ListUnitsByNames([]string{JackServiceName, + SCSynthServiceName, SupernovaServiceName, SCLangServiceName, JackAutoconnectServiceName, + JackTripServiceName, JamulusServiceName, JamulusServerServiceName, JamulusBridgeServiceName}) + if err != nil { + log.Error(err, "Failed to get status of managed services") + panic(err) + } + for _, u := range units { + err = stopService(conn, u) + if err != nil { + log.Error(err, "Unable to stop service") + panic(err) + } + } + + // don't restart if server is not active + if !config.Enabled { + return + } + + // determine which services to start + var servicesToStart []string + switch config.Type { + case client.JackTrip: + servicesToStart = []string{JackServiceName, JackTripServiceName} + if isServer { + servicesToStart = append(servicesToStart, SCSynthServiceName, SCLangServiceName, JackAutoconnectServiceName) + } + case client.Jamulus: + if isServer { + servicesToStart = []string{JackServiceName, JamulusServerServiceName} + } else { + servicesToStart = []string{JackServiceName, JamulusServiceName} + } + case client.JackTripJamulus: + if isServer { + servicesToStart = []string{JackServiceName, JackTripServiceName} + servicesToStart = append(servicesToStart, JamulusServerServiceName, JamulusBridgeServiceName) + servicesToStart = append(servicesToStart, SCSynthServiceName, SCLangServiceName, JackAutoconnectServiceName) + } else { + switch config.Quality { + case 0: + servicesToStart = []string{JackServiceName, JamulusServiceName} + case 1: + servicesToStart = []string{JackServiceName, JamulusServiceName} + case 2: + servicesToStart = []string{JackServiceName, JackTripServiceName} + } + } + } + + // start managed services + for _, serviceName := range servicesToStart { + err = startService(conn, serviceName) + if err != nil { + log.Error(err, "Unable to start service", "name", serviceName) + panic(err) + } + } +} + +// stopService is used to stop a managed systemd service +func stopService(conn *dbus.Conn, u dbus.UnitStatus) error { + if u.ActiveState == "inactive" { + return nil + } + + log.Info("Stopping managed service", "service", u.Name) + + reschan := make(chan string) + _, err := conn.StopUnit(u.Name, "replace", reschan) + if err != nil { + return fmt.Errorf("failed to stop %s: job status=%s", u.Name, err.Error()) + } + + jobStatus := <-reschan + if jobStatus != "done" { + return fmt.Errorf("failed to stop %s: job status=%s", u.Name, jobStatus) + } + + return nil +} + +// startService is used to start a managed systemd service +func startService(conn *dbus.Conn, name string) error { + log.Info("Starting managed service", "service", name) + + reschan := make(chan string) + _, err := conn.StartUnit(name, "replace", reschan) + if err != nil { + return fmt.Errorf("failed to start %s: job status=%s", name, err.Error()) + } + + jobStatus := <-reschan + if jobStatus != "done" { + return fmt.Errorf("failed to start %s: job status=%s", name, jobStatus) + } + + return nil +} + +// startService is used to restart a managed systemd service +func restartService(name string) error { + log.Info("Restarting managed service", "service", name) + + // create dbus connection to manage systemd units + conn, err := dbus.New() + if err != nil { + return errors.New("failed to connect to dbus") + } + defer conn.Close() + + reschan := make(chan string) + _, err = conn.RestartUnit(name, "replace", reschan) + if err != nil { + return fmt.Errorf("failed to restart %s: job status=%s", name, err.Error()) + } + + jobStatus := <-reschan + if jobStatus != "done" { + return fmt.Errorf("failed to restart %s: job status=%s", name, jobStatus) + } + + return nil +} diff --git a/go.mod b/go.mod index 6b1263a..c3b6e0f 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/jacktrip/jacktrip-agent -go 1.13 +go 1.16 require ( github.com/coreos/go-systemd/v22 v22.1.0 @@ -9,5 +9,7 @@ require ( github.com/gorilla/mux v1.8.0 github.com/gorilla/websocket v1.4.2 github.com/jmoiron/sqlx v1.2.0 + github.com/stretchr/testify v1.7.0 go.uber.org/zap v1.16.0 + gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect ) diff --git a/go.sum b/go.sum index 35bbe5d..3e40bf3 100644 --- a/go.sum +++ b/go.sum @@ -38,6 +38,8 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= go.uber.org/atomic v1.6.0 h1:Ezj3JGmsOnG1MoRWQkPBsKLe9DwWD9QeXzTRzzldNVk= go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= @@ -78,5 +80,8 @@ gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= honnef.co/go/tools v0.0.1-2019.2.3 h1:3JgtbtFHMiCmsznwGVTUWbgGov+pVqnlf1dEJTNAXeM= honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= diff --git a/pkg/client/devices.go b/pkg/client/devices.go index 3b8b06f..5cc1fa2 100644 --- a/pkg/client/devices.go +++ b/pkg/client/devices.go @@ -1,4 +1,4 @@ -// Copyright 2020 20hz, LLC +// Copyright 2020-2021 JackTrip Labs, Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/pkg/client/devices_test.go b/pkg/client/devices_test.go new file mode 100644 index 0000000..04dd886 --- /dev/null +++ b/pkg/client/devices_test.go @@ -0,0 +1,145 @@ +// Copyright 2020-2021 JackTrip Labs, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package client + +import ( + "encoding/json" + "github.com/stretchr/testify/assert" + "testing" + "time" +) + +func TestDeviceConfig(t *testing.T) { + assert := assert.New(t) + var raw string + var target DeviceConfig + + // Parse JSON into DeviceConfig struct + raw = `{"devicePort": 8000, "reverb": 42, "limiter": true, "compressor": 0, "quality": 2}` + target = DeviceConfig{} + json.Unmarshal([]byte(raw), &target) + assert.Equal(8000, target.DevicePort) + assert.Equal(42, target.Reverb) + assert.Equal(true, bool(target.Limiter)) + assert.Equal(false, bool(target.Compressor)) + assert.Equal(2, target.Quality) +} + +func TestALSAConfig(t *testing.T) { + assert := assert.New(t) + var raw string + var target ALSAConfig + + // Parse JSON into ALSAConfig struct + raw = `{"captureBoost": true, "playbackBoost": 0, "captureVolume": 100, "playbackVolume": 0}` + target = ALSAConfig{} + json.Unmarshal([]byte(raw), &target) + assert.Equal(true, bool(target.CaptureBoost)) + assert.Equal(false, bool(target.PlaybackBoost)) + assert.Equal(100, target.CaptureVolume) + assert.Equal(0, target.PlaybackVolume) +} + +func TestPingStats(t *testing.T) { + assert := assert.New(t) + var raw string + var target PingStats + + // Parse JSON into PingStats struct + raw = `{"pkts_recv": 832, "pkts_sent": 3, "min_rtt": 3, "max_rtt": -5, "avg_rtt": 301, "stddev_rtt": -10291, "stats_updated_at": "2021-08-11T10:28:32.487013776Z"}` + target = PingStats{} + json.Unmarshal([]byte(raw), &target) + assert.Equal(832, target.PacketsRecv) + assert.Equal(3, target.PacketsSent) + assert.Equal(time.Duration(3), target.MinRtt) + assert.Equal(-1*time.Duration(5), target.MaxRtt) + assert.Equal(time.Duration(301), target.AvgRtt) + assert.Equal(-1*time.Duration(10291), target.StdDevRtt) + assert.Equal("2021-08-11 10:28:32.487013776 +0000 UTC", target.StatsUpdatedAt.String()) +} + +func TestAgentConfig(t *testing.T) { + assert := assert.New(t) + var raw string + var target AgentConfig + + // Parse JSON into AgentConfig struct + raw = `{"period": 3, "queueBuffer": 128, "devicePort": 8000, "reverb": 42, "limiter": true, "compressor": 0, "quality": 2, "captureBoost": true, "playbackBoost": 0, "captureVolume": 100, "playbackVolume": 0, "type": "JackTrip+Jamulus", "mixBranch": "main", "mixCode": "echo hi", "serverHost": "a.b.com", "serverPort": 8000, "sampleRate": 96000, "stereo": true, "loopback": false, "enabled": true}` + target = AgentConfig{} + json.Unmarshal([]byte(raw), &target) + assert.Equal(3, target.Period) + assert.Equal(128, target.QueueBuffer) + assert.Equal(8000, target.DevicePort) + assert.Equal(42, target.Reverb) + assert.Equal(true, bool(target.Limiter)) + assert.Equal(false, bool(target.Compressor)) + assert.Equal(2, target.Quality) + assert.Equal(true, bool(target.CaptureBoost)) + assert.Equal(false, bool(target.PlaybackBoost)) + assert.Equal(100, target.CaptureVolume) + assert.Equal(0, target.PlaybackVolume) + assert.Equal(JackTripJamulus, target.Type) + assert.Equal("main", target.MixBranch) + assert.Equal("echo hi", target.MixCode) + assert.Equal("a.b.com", target.Host) + assert.Equal(8000, target.Port) + assert.Equal(96000, target.SampleRate) + assert.Equal(true, bool(target.Stereo)) + assert.Equal(false, bool(target.LoopBack)) + assert.Equal(true, bool(target.Enabled)) +} + +func TestAgentCredentials(t *testing.T) { + assert := assert.New(t) + var raw string + var target AgentCredentials + + // Parse JSON into AgentCredentials struct + raw = `{"apiPrefix": "black", "apiSecret": "pink"}` + target = AgentCredentials{} + json.Unmarshal([]byte(raw), &target) + assert.Equal("black", target.APIPrefix) + assert.Equal("pink", target.APISecret) +} + +func TestGetAPIHash(t *testing.T) { + assert := assert.New(t) + result := GetAPIHash("blackpink") + assert.Equal("b13dabc4285540382af3f280bfc55c0752806a177f896afa8ec568b0206c3bf5", result) +} + +func TestAgentPing(t *testing.T) { + assert := assert.New(t) + var raw string + var target AgentPing + + // Parse JSON into AgentPing struct + raw = `{"apiPrefix": "black", "apiSecret": "pink", "cloudId": "aws", "mac": "00:1B:44:11:3A:B7", "version": "1.0.0", "type": "snd_rpi_hifiberry_dacplusadcpro", "pkts_recv": 832, "pkts_sent": 3, "min_rtt": 3, "max_rtt": -5, "avg_rtt": 301, "stddev_rtt": -10291, "stats_updated_at": "2021-08-11T10:28:32.487013776Z"}` + target = AgentPing{} + json.Unmarshal([]byte(raw), &target) + assert.Equal("black", target.APIPrefix) + assert.Equal("pink", target.APISecret) + assert.Equal("aws", target.CloudID) + assert.Equal("00:1B:44:11:3A:B7", target.MAC) + assert.Equal("1.0.0", target.Version) + assert.Equal("snd_rpi_hifiberry_dacplusadcpro", target.Type) + assert.Equal(832, target.PacketsRecv) + assert.Equal(3, target.PacketsSent) + assert.Equal(time.Duration(3), target.MinRtt) + assert.Equal(-1*time.Duration(5), target.MaxRtt) + assert.Equal(time.Duration(301), target.AvgRtt) + assert.Equal(-1*time.Duration(10291), target.StdDevRtt) + assert.Equal("2021-08-11 10:28:32.487013776 +0000 UTC", target.StatsUpdatedAt.String()) +} diff --git a/pkg/client/servers.go b/pkg/client/servers.go index 142cdf7..d2425da 100644 --- a/pkg/client/servers.go +++ b/pkg/client/servers.go @@ -1,4 +1,4 @@ -// Copyright 2020 20hz, LLC +// Copyright 2020-2021 JackTrip Labs, Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/pkg/client/servers_test.go b/pkg/client/servers_test.go new file mode 100644 index 0000000..4b9cb2d --- /dev/null +++ b/pkg/client/servers_test.go @@ -0,0 +1,48 @@ +// Copyright 2020-2021 JackTrip Labs, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package client + +import ( + "encoding/json" + "github.com/stretchr/testify/assert" + "testing" +) + +func TestConstants(t *testing.T) { + assert := assert.New(t) + assert.Equal(ServerType("JackTrip"), JackTrip) + assert.Equal(ServerType("Jamulus"), Jamulus) + assert.Equal(ServerType("JackTrip+Jamulus"), JackTripJamulus) +} + +func TestServerConfig(t *testing.T) { + assert := assert.New(t) + var raw string + var target ServerConfig + + // Parse JSON into ServerConfig struct + raw = `{"type": "JackTrip+Jamulus", "mixBranch": "main", "mixCode": "echo hi", "serverHost": "a.b.com", "serverPort": 8000, "sampleRate": 96000, "stereo": true, "loopback": false, "enabled": true}` + target = ServerConfig{} + json.Unmarshal([]byte(raw), &target) + assert.Equal(JackTripJamulus, target.Type) + assert.Equal("main", target.MixBranch) + assert.Equal("echo hi", target.MixCode) + assert.Equal("a.b.com", target.Host) + assert.Equal(8000, target.Port) + assert.Equal(96000, target.SampleRate) + assert.Equal(true, bool(target.Stereo)) + assert.Equal(false, bool(target.LoopBack)) + assert.Equal(true, bool(target.Enabled)) +}