diff --git a/.golangci.yaml b/.golangci.yaml index a1bb4d3..40676b5 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -1,5 +1,5 @@ service: - golangci-lint-version: 1.55.2 + golangci-lint-version: 1.60.3 run: deadline: 900s modules-download-mode: readonly @@ -11,45 +11,39 @@ linters: - containedctx - contextcheck - cyclop - - deadcode - depguard - - exhaustivestruct + - execinquery + - exportloopref - exhaustruct - forcetypeassert - funlen - gocognit - godox - - goerr113 + - err113 - gochecknoglobals - gochecknoinits - gocyclo - gofmt - goimports - - golint - gomnd - - ifshort - importas - interfacebloat - - interfacer - ireturn - maintidx - - maligned - makezero + - mnd - musttag - nestif - nlreturn - - nosnakecase - nonamedreturns - nosprintfhostport - paralleltest + - perfsprint - prealloc - revive - - scopelint - - structcheck - tagliatelle - testpackage - thelper # false positives - - varcheck - varnamelen - wrapcheck - wsl @@ -62,7 +56,6 @@ linters-settings: - default - prefix(github.com/viamrobotics/agent-provisioning) gofumpt: - lang-version: "1.21" extra-rules: true govet: enable-all: true @@ -81,6 +74,9 @@ issues: - exhaustive - goconst - gosec + - path: ^cmd/provisioning-client/ + linters: + - forbidigo exclude-use-default: false max-per-linter: 0 max-same-issues: 0 diff --git a/Makefile b/Makefile index 9dae832..de766b8 100644 --- a/Makefile +++ b/Makefile @@ -16,7 +16,7 @@ else PATH_VERSION = v$(TAG_VERSION) endif -LDFLAGS = "-s -w -X 'github.com/viamrobotics/agent/subsystems/viamagent.Version=${TAG_VERSION}' -X 'github.com/viamrobotics/agent/subsystems/viamagent.GitRevision=${GIT_REVISION}'" +LDFLAGS = "-s -w -X 'github.com/viamrobotics/agent.Version=${TAG_VERSION}' -X 'github.com/viamrobotics/agent.GitRevision=${GIT_REVISION}'" TAGS = osusergo,netgo @@ -33,16 +33,16 @@ arm64: amd64: make GOARCH=amd64 -bin/viam-agent-$(PATH_VERSION)-$(LINUX_ARCH): go.* *.go */*.go */*/*.go subsystems/viamagent/*.service - go build -o $@ -tags $(TAGS) -ldflags $(LDFLAGS) ./cmd/viam-agent/main.go +bin/viam-agent-$(PATH_VERSION)-$(LINUX_ARCH): go.* *.go */*.go */*/*.go subsystems/viamagent/*.service Makefile + go build -o $@ -trimpath -tags $(TAGS) -ldflags $(LDFLAGS) ./cmd/viam-agent/main.go test "$(PATH_VERSION)" != "custom" && cp $@ bin/viam-agent-stable-$(LINUX_ARCH) || true .PHONY: clean clean: rm -rf bin/ -bin/golangci-lint: - GOBIN=`pwd`/bin go install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.55.2 +bin/golangci-lint: Makefile + GOBIN=`pwd`/bin go install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.60.3 .PHONY: lint lint: bin/golangci-lint diff --git a/cmd/provisioning-client/main.go b/cmd/provisioning-client/main.go new file mode 100644 index 0000000..7394376 --- /dev/null +++ b/cmd/provisioning-client/main.go @@ -0,0 +1,147 @@ +package main + +import ( + "bytes" + "context" + "fmt" + "strings" + + "github.com/jessevdk/go-flags" + "github.com/viamrobotics/agent/subsystems/provisioning" + pb "go.viam.com/api/provisioning/v1" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" +) + +func main() { + ctx := context.TODO() + + var opts struct { + Address string `description:"Address/port to dial (ex: 'localhost:4772')" long:"address" short:"a"` + + SSID string `description:"SSID to set" long:"ssid"` + PSK string `description:"PSK/Password for wifi" long:"psk"` + + AppAddr string `default:"https://app.viam.com:443" description:"Cloud address to set in viam.json" long:"appaddr"` + PartID string `description:"PartID to set in viam.json" long:"partID"` + Secret string `description:"Device secret to set in viam.json" long:"secret"` + + Status bool `description:"Get device status" long:"status" short:"s"` + Networks bool `description:"List networks" long:"networks" short:"n"` + Help bool `description:"Show this help message" long:"help" short:"h"` + } + + parser := flags.NewParser(&opts, flags.IgnoreUnknown) + parser.Usage = "runs as a background service and manages updates and the process lifecycle for viam-server." + + _, err := parser.Parse() + if err != nil { + panic(err) + } + + if opts.Address == "" || (opts.PartID == "" && opts.SSID == "" && !opts.Networks && !opts.Status) { + opts.Help = true + } + + if opts.Help { + var b bytes.Buffer + parser.WriteHelp(&b) + + fmt.Println(b.String()) + return + } + + if opts.PartID != "" || opts.Secret != "" { + if opts.PartID == "" || opts.Secret == "" || opts.AppAddr == "" { + fmt.Println("Error: Must set both Secret and PartID (and optionally AppAddr) at the same time!") + return + } + } + + conn, err := grpc.NewClient(opts.Address, grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + fmt.Println(err) + } + defer func() { + err := conn.Close() + if err != nil { + fmt.Println(err) + } + }() + + client := pb.NewProvisioningServiceClient(conn) + + if opts.Status { + GetStatus(ctx, client) + } + + if opts.Networks { + GetNetworks(ctx, client) + } + + if opts.PartID != "" { + SetDeviceCreds(ctx, client, opts.PartID, opts.Secret, opts.AppAddr) + } + + if opts.SSID != "" { + SetWifiCreds(ctx, client, opts.SSID, opts.PSK) + } +} + +func GetStatus(ctx context.Context, client pb.ProvisioningServiceClient) { + resp, err := client.GetSmartMachineStatus(ctx, &pb.GetSmartMachineStatusRequest{}) + if err != nil { + fmt.Println(err) + return + } + + fmt.Printf("Online: %t, Configured: %t, Provisioning: %v, Last: %v, Errors: %s\n", + resp.GetIsOnline(), + resp.GetHasSmartMachineCredentials(), + resp.GetProvisioningInfo(), + resp.GetLatestConnectionAttempt(), + strings.Join(resp.GetErrors(), "\n"), + ) +} + +func GetNetworks(ctx context.Context, client pb.ProvisioningServiceClient) { + resp, err := client.GetNetworkList(ctx, &pb.GetNetworkListRequest{}) + if err != nil { + fmt.Println(err) + return + } + + for _, network := range resp.GetNetworks() { + fmt.Printf("SSID: %s, Signal: %d%%, Security: %s\n", network.GetSsid(), network.GetSignal(), network.GetSecurity()) + } +} + +func SetDeviceCreds(ctx context.Context, client pb.ProvisioningServiceClient, id, secret, appaddr string) { + req := &pb.SetSmartMachineCredentialsRequest{ + Cloud: &pb.CloudConfig{ + Id: id, + Secret: secret, + AppAddress: appaddr, + }, + } + + _, err := client.SetSmartMachineCredentials(ctx, req) + if err != nil { + fmt.Println("Error setting device credentials ", err) + return + } +} + +func SetWifiCreds(ctx context.Context, client pb.ProvisioningServiceClient, ssid, psk string) { + req := &pb.SetNetworkCredentialsRequest{ + Type: provisioning.NetworkTypeWifi, + Ssid: ssid, + Psk: psk, + } + + _, err := client.SetNetworkCredentials(ctx, req) + if err != nil { + fmt.Println("Error setting wifi credentials ", err) + return + } +} diff --git a/cmd/viam-agent/main.go b/cmd/viam-agent/main.go index 7a28f29..64cefa6 100644 --- a/cmd/viam-agent/main.go +++ b/cmd/viam-agent/main.go @@ -20,7 +20,7 @@ import ( "github.com/pkg/errors" "github.com/viamrobotics/agent" "github.com/viamrobotics/agent/subsystems/provisioning" - "github.com/viamrobotics/agent/subsystems/syscfg" + _ "github.com/viamrobotics/agent/subsystems/syscfg" "github.com/viamrobotics/agent/subsystems/viamagent" "github.com/viamrobotics/agent/subsystems/viamserver" "go.viam.com/rdk/logging" @@ -36,16 +36,23 @@ var ( //nolint:gocognit func main() { - ctx := setupExitSignalHandling() + ctx, cancel := setupExitSignalHandling() + defer func() { + cancel() + activeBackgroundWorkers.Wait() + }() + + //nolint:lll var opts struct { - Config string `default:"/etc/viam.json" description:"Path to config file" long:"config" short:"c"` - Debug bool `description:"Enable debug logging (for agent only)" env:"VIAM_AGENT_DEBUG" long:"debug" short:"d"` - Fast bool `description:"Enable fast start mode" env:"VIAM_AGENT_FAST_START" long:"fast" short:"f"` - Help bool `description:"Show this help message" long:"help" short:"h"` - Version bool `description:"Show version" long:"version" short:"v"` - Install bool `description:"Install systemd service" long:"install"` - DevMode bool `description:"Allow non-root and non-service" env:"VIAM_AGENT_DEVMODE" long:"dev-mode"` + Config string `default:"/etc/viam.json" description:"Path to config file" long:"config" short:"c"` + ProvisioningConfig string `default:"/etc/viam-provisioning.json" description:"Path to provisioning (customization) config file" long:"provisioning" short:"p"` + Debug bool `description:"Enable debug logging (agent only)" env:"VIAM_AGENT_DEBUG" long:"debug" short:"d"` + Fast bool `description:"Enable fast start mode" env:"VIAM_AGENT_FAST_START" long:"fast" short:"f"` + Help bool `description:"Show this help message" long:"help" short:"h"` + Version bool `description:"Show version" long:"version" short:"v"` + Install bool `description:"Install systemd service" long:"install"` + DevMode bool `description:"Allow non-root and non-service" env:"VIAM_AGENT_DEVMODE" long:"dev-mode"` } parser := flags.NewParser(&opts, flags.IgnoreUnknown) @@ -64,14 +71,12 @@ func main() { if opts.Version { //nolint:forbidigo - fmt.Printf("Version: %s\nGit Revision: %s\n", viamagent.GetVersion(), viamagent.GetRevision()) + fmt.Printf("Version: %s\nGit Revision: %s\n", agent.GetVersion(), agent.GetRevision()) return } if opts.Debug { globalLogger = logging.NewDebugLogger("viam-agent") - provisioning.Debug = true - syscfg.Debug = true } // need to be root to go any further than this @@ -112,10 +117,15 @@ func main() { } }() + // pass the provisioning path arg to the subsystem + absProvConfigPath, err := filepath.Abs(opts.ProvisioningConfig) + exitIfError(err) + provisioning.ProvisioningConfigFilePath = absProvConfigPath + globalLogger.Infof("provisioning config file path: %s", absProvConfigPath) + // tie the manager config to the viam-server config absConfigPath, err := filepath.Abs(opts.Config) exitIfError(err) - viamserver.ConfigFilePath = absConfigPath provisioning.AppConfigFilePath = absConfigPath globalLogger.Infof("config file path: %s", absConfigPath) @@ -156,7 +166,6 @@ func main() { globalLogger.Warn("waiting for user provisioning") if !utils.SelectContextOrWait(ctx, time.Second*10) { manager.CloseAll() - activeBackgroundWorkers.Wait() return } if err := manager.LoadConfig(absConfigPath); err == nil { @@ -209,6 +218,7 @@ func main() { globalLogger.Error(err) } if needRestart { + manager.CloseAll() globalLogger.Info("updated self, exiting to await restart with new version") return } @@ -217,11 +227,9 @@ func main() { manager.StartBackgroundChecks(ctx) <-ctx.Done() manager.CloseAll() - - activeBackgroundWorkers.Wait() } -func setupExitSignalHandling() context.Context { +func setupExitSignalHandling() (context.Context, func()) { ctx, cancel := context.WithCancel(context.Background()) sigChan := make(chan os.Signal, 16) activeBackgroundWorkers.Add(1) @@ -266,7 +274,7 @@ func setupExitSignalHandling() context.Context { }() signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM, syscall.SIGABRT) - return ctx + return ctx, cancel } func exitIfError(err error) { diff --git a/go.mod b/go.mod index 26f6ea1..3501da3 100644 --- a/go.mod +++ b/go.mod @@ -1,21 +1,23 @@ module github.com/viamrobotics/agent -go 1.21.13 - -toolchain go1.22.7 +go 1.23.1 require ( - github.com/jessevdk/go-flags v1.5.0 + github.com/Otterverse/gonetworkmanager/v2 v2.2.0 + github.com/google/uuid v1.6.0 + github.com/jessevdk/go-flags v1.6.1 github.com/nightlyone/lockfile v1.0.0 github.com/pkg/errors v0.9.1 + github.com/sergeymakinen/go-systemdconf/v2 v2.0.2 github.com/ulikunitz/xz v0.5.12 go.uber.org/zap v1.27.0 - go.viam.com/api v0.1.322 - go.viam.com/rdk v0.33.1 + go.viam.com/api v0.1.345 + go.viam.com/rdk v0.45.0 go.viam.com/test v1.1.1-0.20220913152726-5da9916c08a2 - go.viam.com/utils v0.1.102 - golang.org/x/sys v0.25.0 - google.golang.org/protobuf v1.34.1 + go.viam.com/utils v0.1.104 + golang.org/x/sys v0.26.0 + google.golang.org/grpc v1.67.1 + google.golang.org/protobuf v1.35.1 ) require ( @@ -27,12 +29,12 @@ require ( github.com/edaniels/golog v0.0.0-20230215213219-28954395e8d0 // indirect github.com/edaniels/zeroconf v1.0.10 // indirect github.com/goccy/go-json v0.10.2 // indirect + github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/golang-jwt/jwt/v4 v4.5.0 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect - github.com/golang/protobuf v1.5.3 // indirect + github.com/golang/protobuf v1.5.4 // indirect github.com/golang/snappy v0.0.4 // indirect github.com/google/go-cmp v0.6.0 // indirect - github.com/google/uuid v1.6.0 // indirect github.com/gorilla/securecookie v1.1.1 // indirect github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.18.0 // indirect @@ -62,7 +64,7 @@ require ( github.com/pion/transport/v2 v2.2.10 // indirect github.com/pion/turn/v2 v2.1.6 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/rs/cors v1.11.0 // indirect + github.com/rs/cors v1.11.1 // indirect github.com/smartystreets/assertions v1.13.0 // indirect github.com/srikrsna/protoc-gen-gotag v0.6.2 // indirect github.com/stretchr/testify v1.9.0 // indirect @@ -78,17 +80,14 @@ require ( go.uber.org/goleak v1.3.0 // indirect go.uber.org/multierr v1.11.0 // indirect golang.org/x/crypto v0.27.0 // indirect - golang.org/x/mod v0.17.0 // indirect + golang.org/x/mod v0.21.0 // indirect golang.org/x/net v0.29.0 // indirect - golang.org/x/oauth2 v0.13.0 // indirect + golang.org/x/oauth2 v0.22.0 // indirect golang.org/x/sync v0.8.0 // indirect golang.org/x/text v0.18.0 // indirect - golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect - google.golang.org/appengine v1.6.8 // indirect - google.golang.org/genproto v0.0.0-20231016165738-49dd2c1f3d0b // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20231016165738-49dd2c1f3d0b // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20231016165738-49dd2c1f3d0b // indirect - google.golang.org/grpc v1.59.0 // indirect + golang.org/x/tools v0.24.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20240827150818-7e3bb234dfed // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 // indirect gopkg.in/square/go-jose.v2 v2.6.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect nhooyr.io/websocket v1.8.9 // indirect diff --git a/go.sum b/go.sum index 9f67a38..ed2ddae 100644 --- a/go.sum +++ b/go.sum @@ -14,23 +14,26 @@ cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bP cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk= cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs= cloud.google.com/go v0.60.0/go.mod h1:yw2G51M9IfRboUH61Us8GqCeF1PzPblB823Mn2q2eAU= -cloud.google.com/go v0.110.8 h1:tyNdfIxjzaWctIiLYOTalaLKZ17SI44SKFW26QbOhME= -cloud.google.com/go v0.110.8/go.mod h1:Iz8AkXJf1qmxC3Oxoep8R1T36w8B92yU29PcBhHO5fk= +cloud.google.com/go v0.115.1 h1:Jo0SM9cQnSkYfp44+v+NQXHpcHqlnRJk2qxh6yvxxxQ= +cloud.google.com/go v0.115.1/go.mod h1:DuujITeaufu3gL68/lOFIirVNJwQeyf5UXyi+Wbgknc= +cloud.google.com/go/auth v0.9.3 h1:VOEUIAADkkLtyfr3BLa3R8Ed/j6w1jTBmARx+wb5w5U= +cloud.google.com/go/auth v0.9.3/go.mod h1:7z6VY+7h3KUdRov5F1i8NDP5ZzWKYmEPO842BgCsmTk= +cloud.google.com/go/auth/oauth2adapt v0.2.4 h1:0GWE/FUsXhf6C+jAkWgYm7X9tK8cuEIfy19DBn6B6bY= +cloud.google.com/go/auth/oauth2adapt v0.2.4/go.mod h1:jC/jOpwFP6JBxhB3P5Rr0a9HLMC/Pe3eaL4NmdvqPtc= cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE= cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc= cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg= cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc= cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ= -cloud.google.com/go/compute v1.23.1 h1:V97tBoDaZHb6leicZ1G6DLK2BAaZLJ/7+9BB/En3hR0= -cloud.google.com/go/compute v1.23.1/go.mod h1:CqB3xpmPKKt3OJpW2ndFIXnA9A4xAy/F3Xp1ixncW78= -cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGBW5aJ7UnBMY= -cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA= +cloud.google.com/go/compute v1.21.0 h1:JNBsyXVoOoNJtTQcnEY5uYpZIbeCTYIeDe0Xh1bySMk= +cloud.google.com/go/compute/metadata v0.5.0 h1:Zr0eK8JbFv6+Wi4ilXAR8FJ3wyNdpxHKJNPos6LTZOY= +cloud.google.com/go/compute/metadata v0.5.0/go.mod h1:aHnloV2TPI38yx4s9+wAZhHykWvVCfu7hQbF+9CWoiY= cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk= cloud.google.com/go/firestore v1.1.0/go.mod h1:ulACoGHTpvq5r8rxGJ4ddJZBZqakUQqClKRT5SZwBmk= -cloud.google.com/go/iam v1.1.3 h1:18tKG7DzydKWUnLjonWcJO6wjSCAtzh4GcRKlH/Hrzc= -cloud.google.com/go/iam v1.1.3/go.mod h1:3khUlaBXfPKKe7huYgEpDn6FtgRyMEqbkvBxrQyY5SE= +cloud.google.com/go/iam v1.2.0 h1:kZKMKVNk/IsSSc/udOb83K0hL/Yh/Gcqpz+oAkoIFN8= +cloud.google.com/go/iam v1.2.0/go.mod h1:zITGuWgsLZxd8OwAlX+eMFgZDXzBm7icj1PVTYG766Q= cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw= cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA= @@ -41,8 +44,8 @@ cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiy cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos= cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk= cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs= -cloud.google.com/go/storage v1.30.1 h1:uOdMxAs8HExqBlnLtnQyP0YkvbiDpdGShGKtx6U/oNM= -cloud.google.com/go/storage v1.30.1/go.mod h1:NfxhC0UJE1aXSx7CIIbCf7y9HKT7BiccwkR7+P7gN8E= +cloud.google.com/go/storage v1.43.0 h1:CcxnSohZwizt4LCzQHWvBf1/kvtHUn7gk9QERXPyXFs= +cloud.google.com/go/storage v1.43.0/go.mod h1:ajvxEa7WmZS1PxvKRq4bq0tFT3vMd502JwstCcYv0Q0= contrib.go.opencensus.io/exporter/stackdriver v0.13.4/go.mod h1:aXENhDJ1Y4lIg4EUaVTwzvYETVNZk10Pu26tevFKLUc= dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= @@ -56,6 +59,9 @@ github.com/Masterminds/sprig v2.15.0+incompatible/go.mod h1:y6hNFY5UBTIWBxnzTeuN github.com/Masterminds/sprig v2.22.0+incompatible/go.mod h1:y6hNFY5UBTIWBxnzTeuNhlNS5hqE0NB0E6fgfo2Br3o= github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= github.com/OpenPeeDeeP/depguard v1.0.1/go.mod h1:xsIw86fROiiwelg+jB2uM9PiKihMMmUx/1V+TNhjQvM= +github.com/Otterverse/gonetworkmanager/v2 v2.2.0 h1:aYEOjBO2I+OMORNpCBXqvVgzHcGks5MWmKv0JmrT5po= +github.com/Otterverse/gonetworkmanager/v2 v2.2.0/go.mod h1:Bc8kOugBgzCBC0R8oLa3wHnGet7k2ZpMHUobZtxlwhU= +github.com/PuerkitoBio/goquery v1.6.0/go.mod h1:GsLWisAFVj4WgDibEWF4pvYnkVQBpKBKeU+7zCJoLcc= github.com/Shopify/sarama v1.19.0/go.mod h1:FVkBWblsNy7DGZRfXLU0O9RCGt5g3g3yEuWXgklEdEo= github.com/Shopify/toxiproxy v2.1.4+incompatible/go.mod h1:OXgGpZ6Cli1/URJOF1DMxUHB2q5Ap20/P/eIdh4G0pI= github.com/StackExchange/wmi v0.0.0-20190523213315-cbe66965904d/go.mod h1:3eOhrUMpNV+6aFIbp5/iudMxNCF27Vw2OZgy4xEx0Fg= @@ -68,6 +74,8 @@ github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRF github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho= github.com/alexkohler/prealloc v1.0.0/go.mod h1:VetnK3dIgFBBKmg0YnD9F9x6Icjd+9cvfHR56wJVlKE= github.com/andybalholm/brotli v1.0.0/go.mod h1:loMXtMfwqflxFJPmdbJO0a3KNoPuLBgiu3qAvBg8x/Y= +github.com/andybalholm/cascadia v1.1.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y= +github.com/andybalholm/cascadia v1.2.0/go.mod h1:YCyR8vOZT9aZ1CHEd8ap0gMVm2aFgxBp0T0eFw1RUQY= github.com/antihax/optional v0.0.0-20180407024304-ca021399b1a6/go.mod h1:V8iCPQYkqmusNa815XgQio277wI47sdRh1dUOLdyC6Q= github.com/aokoli/goutils v1.0.1/go.mod h1:SijmP0QR8LtwsmDs8Yii5Z/S4trXFGFC2oO5g9DP+DQ= github.com/apache/thrift v0.12.0/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb+bacwQ= @@ -167,6 +175,8 @@ github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5Kwzbycv github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU= github.com/fatih/color v1.10.0/go.mod h1:ELkj/draVOlAH/xkhN6mQ50Qd0MPOk5AAr3maGEBuJM= github.com/fatih/structtag v1.2.0/go.mod h1:mBJUNpUnHmRKrKlQQlmCrh5PuhftFbNv8Ys4/aAZl94= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/franela/goblin v0.0.0-20200105215937-c9ffbefa60db/go.mod h1:7dvUGVsVBjqR7JHJk0brhHOZYGmfBYOrK0ZhYMEtBr4= github.com/franela/goreq v0.0.0-20171204163338-bcd34c9993f8/go.mod h1:ZhphrRTfi2rbfLwlschooIH4+wKKDR4Pdxhh+TRoA20= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= @@ -190,6 +200,10 @@ github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vb github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-ole/go-ole v1.2.4/go.mod h1:XCwSNxSkXRo4vlyPy93sltvi/qJq0jqQhjqQNIwKuxM= github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8= @@ -216,6 +230,8 @@ github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6Wezm github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM= github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= +github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/gofrs/flock v0.8.0/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU= github.com/gogo/googleapis v1.1.0/go.mod h1:gf4bu3Q80BeJ6H1S1vYPm8/ELATdvryBaNFGgqEef3s= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= @@ -228,8 +244,8 @@ github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69 github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg= github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= -github.com/golang/glog v1.1.2 h1:DVjP2PbBOzHyzA+dn3WhHIq4NdVu3Q+pvivFICf/7fo= -github.com/golang/glog v1.1.2/go.mod h1:zR+okUeTbrL6EL3xHUDxZuEtGv04p5shwip1+mL/rLQ= +github.com/golang/glog v1.2.2 h1:1+mZ9upx1Dh6FmUTFR1naJ77miKiXgALjWOZ3NVFPmY= +github.com/golang/glog v1.2.2/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w= github.com/golang/groupcache v0.0.0-20160516000752-02826c3e7903/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= @@ -261,8 +277,8 @@ github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= -github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= -github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= @@ -304,8 +320,8 @@ github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hf github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/google/pprof v0.0.0-20200507031123-427632fa3b1c/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= -github.com/google/s2a-go v0.1.4 h1:1kZ/sQM3srePvKs3tXAvQzo66XfcReoqFpIpIccE7Oc= -github.com/google/s2a-go v0.1.4/go.mod h1:Ej+mSEMGRnqRzjc7VtF+jdBwYG5fuJfiZ8ELkjEwM0A= +github.com/google/s2a-go v0.1.8 h1:zZDs9gcbt9ZPLV0ndSyQk6Kacx2g/X+SKYovpnz3SMM= +github.com/google/s2a-go v0.1.8/go.mod h1:6iNWHTpQ+nfNRN5E00MSdfDwVesa8hhS32PhPO8deJA= github.com/google/trillian v1.3.11/go.mod h1:0tPraVHrSDkA3BO6vKX67zgLXs6SsOAbHEivX+9mPgw= github.com/google/uuid v0.0.0-20161128191214-064e2069ce9c/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.0.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= @@ -314,12 +330,12 @@ github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+ github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/googleapis/enterprise-certificate-proxy v0.2.3 h1:yk9/cqRKtT9wXZSsRH9aurXEpJX+U6FLtpYTdC3R06k= -github.com/googleapis/enterprise-certificate-proxy v0.2.3/go.mod h1:AwSRAtLfXpU5Nm3pW+v7rGDHp09LsPtGY9MduiEsR9k= +github.com/googleapis/enterprise-certificate-proxy v0.3.3 h1:QRje2j5GZimBzlbhGA2V2QlGNgL8G6e+wGo/+/2bWI0= +github.com/googleapis/enterprise-certificate-proxy v0.3.3/go.mod h1:YKe7cfqYXjKGpGvmSg28/fFvhNzinZQm8DGnaburhGA= github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= -github.com/googleapis/gax-go/v2 v2.11.0 h1:9V9PWXEsWnPpQhu/PeQIkS4eGzMlTLGgt80cUUI8Ki4= -github.com/googleapis/gax-go/v2 v2.11.0/go.mod h1:DxmR61SGKkGLa2xigwuZIQpkCI2S5iydzRfb3peWZJI= +github.com/googleapis/gax-go/v2 v2.13.0 h1:yitjD5f7jQHhyDsnhKEBU52NdvvdSeGzlAnDPT0hH1s= +github.com/googleapis/gax-go/v2 v2.13.0/go.mod h1:Z/fvTZXF8/uw7Xu5GuslPw+bplx6SS338j1Is2S+B7A= github.com/gookit/color v1.3.6/go.mod h1:R3ogXq2B9rTbXoSHJ1HyUVAZ3poOJHpd9nQmyGZsfvQ= github.com/gookit/color v1.3.8/go.mod h1:R3ogXq2B9rTbXoSHJ1HyUVAZ3poOJHpd9nQmyGZsfvQ= github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= @@ -390,8 +406,8 @@ github.com/improbable-eng/grpc-web v0.15.0 h1:BN+7z6uNXZ1tQGcNAuaU1YjsLTApzkjt2t github.com/improbable-eng/grpc-web v0.15.0/go.mod h1:1sy9HKV4Jt9aEs9JSnkWlRJPuPtwNr0l57L4f878wP8= github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= github.com/influxdata/influxdb1-client v0.0.0-20191209144304-8bf82d3c094d/go.mod h1:qj24IKcXYK6Iy9ceXlo3Tc+vtHo9lIhSX5JddghvEPo= -github.com/jessevdk/go-flags v1.5.0 h1:1jKYvbxEjfUl0fmqTCOfonvskHHXMjBySTLW4y9LFvc= -github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4= +github.com/jessevdk/go-flags v1.6.1 h1:Cvu5U8UGrLay1rZfv/zP7iLpSHGUZ/Ou68T0iX1bBK4= +github.com/jessevdk/go-flags v1.6.1/go.mod h1:Mk8T1hIAWpOiJiHa9rJASDK2UGWji0EuPGBnNLMooyc= github.com/jgautheron/goconst v1.4.0/go.mod h1:aAosetZ5zaeC/2EfMeRswtxUFBpe2Hr7HzkgX4fanO4= github.com/jhump/protoreflect v1.6.1/go.mod h1:RZQ/lnuN+zqeRVpQigTwO6o0AJUkxbnSnpuG7toUTG4= github.com/jingyugao/rowserrcheck v0.0.0-20210130005344-c6a0c12dd98d/go.mod h1:/EZlaYCnEX24i7qdVhT9du5JrtFWYRQr67bVgR7JJC8= @@ -689,8 +705,8 @@ github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFR github.com/rogpeppe/go-internal v1.6.2 h1:aIihoIOHCiLZHxyoNQ+ABL4NKhFTgKLBdMLyEAh98m0= github.com/rogpeppe/go-internal v1.6.2/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= github.com/rs/cors v1.7.0/go.mod h1:gFx+x8UowdsKA9AchylcLynDq+nNFfI8FkUZdN/jGCU= -github.com/rs/cors v1.11.0 h1:0B9GE/r9Bc2UxRMMtymBkHTenPkHDv0CW4Y98GBY+po= -github.com/rs/cors v1.11.0/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= +github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA= +github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g= github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/ryancurrah/gomodguard v1.2.0/go.mod h1:rNqbC4TOIdUDcVMSIpNNAzTbzXAZa6W5lnUepvuMMgQ= @@ -702,6 +718,8 @@ github.com/sanposhiho/wastedassign v0.2.0/go.mod h1:LGpq5Hsv74QaqM47WtIsRSF/ik9k github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= github.com/securego/gosec/v2 v2.6.1/go.mod h1:I76p3NTHBXsGhybUW+cEQ692q2Vp+A0Z6ZLzDIZy+Ao= github.com/securego/gosec/v2 v2.7.0/go.mod h1:xNbGArrGUspJLuz3LS5XCY1EBW/0vABAl/LWfSklmiM= +github.com/sergeymakinen/go-systemdconf/v2 v2.0.2 h1:Jp64r8tU8sPBfIX8IGZf+K6zd7uErBDb8wgLkwF8DpA= +github.com/sergeymakinen/go-systemdconf/v2 v2.0.2/go.mod h1:bAlieJ3rWE61zwv4Vv/gPsr5GUMLiPdfdNDDSts4ngY= github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= github.com/shazow/go-diff v0.0.0-20160112020656-b6b7b6733b8c/go.mod h1:/PevMnwAxekIXwN8qQyfc5gl2NlkB3CQlkizAbOkeBs= github.com/shirou/gopsutil/v3 v3.21.1/go.mod h1:igHnfak0qnw1biGeI2qKQvu0ZkwvEkUcCLlYhZzdr/4= @@ -836,6 +854,16 @@ go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0 h1:r6I7RJCN86bpD/FQwedZ0vSixDpwuWREjW9oRMsmqDc= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0/go.mod h1:B9yO6b04uB80CzjedvewuqDhxJxi11s7/GtiGa8bAjI= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8= +go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw= +go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8= +go.opentelemetry.io/otel/metric v1.29.0 h1:vPf/HFWTNkPu1aYeIsc98l4ktOQaL6LeSoeV2g+8YLc= +go.opentelemetry.io/otel/metric v1.29.0/go.mod h1:auu/QWieFVWx+DmQOUMgj0F8LHWdgalxXqvp7BII/W8= +go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4= +go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ= go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= @@ -859,14 +887,14 @@ go.uber.org/zap v1.18.1/go.mod h1:xg/QME4nWcxGxrpdeYfq7UvYrLh66cuVKdrbD1XF/NI= go.uber.org/zap v1.23.0/go.mod h1:D+nX8jyLsMHMYrln8A0rJjFt/T/9/bGgIhAqxv5URuY= go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= -go.viam.com/api v0.1.322 h1:bC78B6QOxUgfWU2rOvN+4n03gg1JgZUPF9VN49X9GQc= -go.viam.com/api v0.1.322/go.mod h1:msa4TPrMVeRDcG4YzKA/S6wLEUC7GyHQE973JklrQ10= -go.viam.com/rdk v0.33.1 h1:hggXaaTDpo0pj+rN0ptVWcgNdbsseUu3S1k51Ok7zn0= -go.viam.com/rdk v0.33.1/go.mod h1:0W7jELL/F/IZokDXzpoLVdeYsiJyldEHaG15wqQwdgI= +go.viam.com/api v0.1.345 h1:QE7KWhkgIpclAG/aJSI3kIi6Mu5bLhutgrTLCy+QzfI= +go.viam.com/api v0.1.345/go.mod h1:5lpVRxMsKFCaahqsnJfPGwJ9baoQ6PIKQu3lxvy6Wtw= +go.viam.com/rdk v0.45.0 h1:AKao5GxwUpAp5Rm+qh4hTpsGkkLUqvPRgx8eaha/55c= +go.viam.com/rdk v0.45.0/go.mod h1:sTBSDOzmC5YMUx7Ca4W7Q9R+aRqYmS24DEQy62pKXMU= go.viam.com/test v1.1.1-0.20220913152726-5da9916c08a2 h1:oBiK580EnEIzgFLU4lHOXmGAE3MxnVbeR7s1wp/F3Ps= go.viam.com/test v1.1.1-0.20220913152726-5da9916c08a2/go.mod h1:XM0tej6riszsiNLT16uoyq1YjuYPWlRBweTPRDanIts= -go.viam.com/utils v0.1.102 h1:D+LmGr/ClDPGMdrgGxKTJbG9pkYOtbZ2USNO1MTgZcQ= -go.viam.com/utils v0.1.102/go.mod h1:SYvcY/TKy9yv1d95era4IEehImkXffWu/5diDBS/4X4= +go.viam.com/utils v0.1.104 h1:UrO870aDYf48iw81Gts/r/9OaqNEpTKYF5tzQ2im5tw= +go.viam.com/utils v0.1.104/go.mod h1:SYvcY/TKy9yv1d95era4IEehImkXffWu/5diDBS/4X4= golang.org/x/crypto v0.0.0-20180501155221-613d6eafa307/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= @@ -926,8 +954,9 @@ golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= -golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0= +golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= +golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -968,6 +997,7 @@ golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81R golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201202161906-c7110b5ffcbb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= golang.org/x/net v0.0.0-20210423184538-5f58ad60dda6/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= @@ -987,8 +1017,8 @@ golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4Iltr golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.13.0 h1:jDDenyj+WgFtmV3zYVoi8aE2BwtXFLWOA67ZfNWftiY= -golang.org/x/oauth2 v0.13.0/go.mod h1:/JMhi4ZRXAf4HG9LiNmxvk+45+96RUlVThiH8FzNBn0= +golang.org/x/oauth2 v0.22.0 h1:BzDx2FehcG7jJwgWLELCdmLuxk2i+x9UDpSiss2u0ZA= +golang.org/x/oauth2 v0.22.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -1061,7 +1091,6 @@ golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210217105451-b926d437f341/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210228012217-479acdf4ea46/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210303074136-134d130e1a04/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -1078,8 +1107,8 @@ golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= -golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= +golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -1110,6 +1139,8 @@ golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxb golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20200416051211-89c76fbcd5d1/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U= +golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180525024113-a5b4c53f6e8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -1202,14 +1233,12 @@ golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= -golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +golang.org/x/tools v0.24.0 h1:J1shsA93PJUEVaUSaay7UXAyE8aimq3GW0pjlolpa24= +golang.org/x/tools v0.24.0/go.mod h1:YhNqVBIfWHdzvTLs0d8LCuMhkKUgSUKldakyV7W/WDQ= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 h1:H2TDz8ibqkAF6YGhCdN3jS9O0/s90v0rJh3X/OLHEUk= -golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8= google.golang.org/api v0.3.1/go.mod h1:6wY9I6uQWHQ8EM57III9mq/AjF+i8G65rmVagqKMtkk= google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= @@ -1227,8 +1256,8 @@ google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/ google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM= -google.golang.org/api v0.126.0 h1:q4GJq+cAdMAC7XP7njvQ4tvohGLiSlytuL4BQxbIZ+o= -google.golang.org/api v0.126.0/go.mod h1:mBwVAtz+87bEN6CbA1GtZPDOqY2R5ONPqJeIlvyo4Aw= +google.golang.org/api v0.196.0 h1:k/RafYqebaIJBO3+SMnfEGtFVlvp5vSgqTUF54UN/zg= +google.golang.org/api v0.196.0/go.mod h1:g9IL21uGkYgvQ5BZg6BAtoGJQIm8r6EgaAbpNey5wBE= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.2.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= @@ -1237,8 +1266,6 @@ google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww google.golang.org/appengine v1.6.2/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= -google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= -google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= google.golang.org/genproto v0.0.0-20170818010345-ee236bd376b0/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20181107211654-5fc9ac540362/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= @@ -1272,12 +1299,12 @@ google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEY google.golang.org/genproto v0.0.0-20200626011028-ee7919e894b5/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20200707001353-8e8330bf89df/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20210126160654-44e461bb6506/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20231016165738-49dd2c1f3d0b h1:+YaDE2r2OG8t/z5qmsh7Y+XXwCbvadxxZ0YY6mTdrVA= -google.golang.org/genproto v0.0.0-20231016165738-49dd2c1f3d0b/go.mod h1:CgAqfJo+Xmu0GwA0411Ht3OU3OntXwsGmrmjI8ioGXI= -google.golang.org/genproto/googleapis/api v0.0.0-20231016165738-49dd2c1f3d0b h1:CIC2YMXmIhYw6evmhPxBKJ4fmLbOFtXQN/GV3XOZR8k= -google.golang.org/genproto/googleapis/api v0.0.0-20231016165738-49dd2c1f3d0b/go.mod h1:IBQ646DjkDkvUIsVq/cc03FUFQ9wbZu7yE396YcL870= -google.golang.org/genproto/googleapis/rpc v0.0.0-20231016165738-49dd2c1f3d0b h1:ZlWIi1wSK56/8hn4QcBp/j9M7Gt3U/3hZw3mC7vDICo= -google.golang.org/genproto/googleapis/rpc v0.0.0-20231016165738-49dd2c1f3d0b/go.mod h1:swOH3j0KzcDDgGUWr+SNpyTen5YrXjS3eyPzFYKc6lc= +google.golang.org/genproto v0.0.0-20240903143218-8af14fe29dc1 h1:BulPr26Jqjnd4eYDVe+YvyR7Yc2vJGkO5/0UxD0/jZU= +google.golang.org/genproto v0.0.0-20240903143218-8af14fe29dc1/go.mod h1:hL97c3SYopEHblzpxRL4lSs523++l8DYxGM1FQiYmb4= +google.golang.org/genproto/googleapis/api v0.0.0-20240827150818-7e3bb234dfed h1:3RgNmBoI9MZhsj3QxC+AP/qQhNwpCLOvYDYYsFrhFt0= +google.golang.org/genproto/googleapis/api v0.0.0-20240827150818-7e3bb234dfed/go.mod h1:OCdP9MfskevB/rbYvHTsXTtKC+3bHWajPdoKgjcYkfo= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 h1:pPJltXNxVzT4pK9yD8vR9X75DaWYYmLGMsEvBfFQZzQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU= google.golang.org/grpc v1.8.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw= google.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= @@ -1298,8 +1325,8 @@ google.golang.org/grpc v1.29.0/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3Iji google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= google.golang.org/grpc v1.32.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= -google.golang.org/grpc v1.59.0 h1:Z5Iec2pjwb+LEOqzpB2MR12/eKFhDPhuqW91O+4bwUk= -google.golang.org/grpc v1.59.0/go.mod h1:aUPDwccQo6OTjy7Hct4AfBPD1GptF4fyUjIkQ9YtF98= +google.golang.org/grpc v1.67.1 h1:zWnc1Vrcno+lHZCOofnIMvycFcc0QRGIzm9dhnDX68E= +google.golang.org/grpc v1.67.1/go.mod h1:1gLDyUQU7CTLJI90u3nXZ9ekeghjeM7pTDZlqFNg2AA= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= @@ -1312,8 +1339,8 @@ google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGj google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= -google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA= +google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/install.sh b/install.sh index 4ed92ba..6adb67a 100755 --- a/install.sh +++ b/install.sh @@ -10,7 +10,6 @@ ARCH=$(uname -m) URL="https://storage.googleapis.com/packages.viam.com/apps/viam-agent/viam-agent-stable-$ARCH" -PROVISIONING_URL="https://storage.googleapis.com/packages.viam.com/apps/viam-agent-provisioning/viam-agent-provisioning-stable-$ARCH" # Force will bypass all prompts by treating them as yes. May also be set as an environment variable when running as download. # sudo /bin/sh -c "FORCE=1; $(curl -fsSL https://storage.googleapis.com/packages.viam.com/apps/viam-agent/install.sh)" @@ -47,6 +46,20 @@ uninstall_old_service() { # Uses API keys and ID provided as env vars to fetch and install /etc/viam.sjon fetch_config() { if [ "$VIAM_API_KEY_ID" != "" ] && [ "$VIAM_API_KEY" != "" ] && [ "$VIAM_PART_ID" != "" ]; then + + if [ -f /etc/viam.json ] && ! [ -z $FORCE ]; then + echo + echo "/etc/viam.json already exists." + echo + echo "Do you wish to overwrite it with an updated version fetched using the VIAM_PART_ID provided?" + echo && echo + read -p "Overwrite /etc/viam.json ? (y/n): " OVERWRITE_CREDS + if [ "$OVERWRITE_CREDS" != "y" ]; then + return + fi + fi + + echo "Writing machine credentials to /etc/viam.json" curl -fsSL \ -H "key_id:$VIAM_API_KEY_ID" \ -H "key:$VIAM_API_KEY" \ @@ -154,14 +167,6 @@ enable_networkmanager() { return 1 fi - echo - echo "Pre-installing provisioning subsystem as a backup." - - mkdir -p /opt/viam/bin /opt/viam/tmp - cd /opt/viam/tmp && curl -fL -o viam-agent-provisioning-temp-$ARCH "$PROVISIONING_URL" && \ - chmod 755 viam-agent-provisioning-temp-$ARCH && ln -s /opt/viam/tmp/viam-agent-provisioning-temp-$ARCH ../bin/agent-provisioning - - if is_bullseye; then echo 'deb http://deb.debian.org/debian/ bullseye-backports main' > /etc/apt/sources.list.d/backports.list && \ apt update && apt install -y network-manager/bullseye-backports || (echo "Failed to upgrade NetworkManager" && return 1) @@ -206,6 +211,18 @@ main() { exit 1 fi + if [ "$ARCH" = "aarch64" ] && ! [ -e /lib/ld-linux-aarch64.so.1 ]; then + echo + echo "Your kernel reports as aarch64 (arm64), but userspace is missing /lib/ld-linux-aarch64.so.1" + echo "Please ensure that you've installed a fully 64-bit version of your distro, including userspace, then retry this install." + exit 1 + elif [ "$ARCH" = "x86_64" ] && ! [ -e /lib64/ld-linux-x86-64.so.2 ]; then + echo + echo "Your kernel reports as x86_64 (amd64), but userspace is missing /lib/ld-linux-x86-64.so.2" + echo "Please ensure that you've installed a fully 64-bit version of your distro, including userspace, then retry this install." + exit 1 + fi + if ! [ -d /etc/systemd/system ]; then echo echo "Viam Agent is only supported on systems using systemd." @@ -218,6 +235,9 @@ main() { exit 1 fi + # Remove old AppImage based install + uninstall_old_service + # Attempt to fetch the config using API keys (if set) fetch_config @@ -241,8 +261,6 @@ main() { fi fi - uninstall_old_service - if [ -f /etc/systemd/system/viam-agent.service ] || [ -f /usr/local/lib/systemd/system/viam-agent.service ]; then echo echo "It appears viam-agent is already installed. You can restart it with 'systemctl restart viam-agent' if it's not running." diff --git a/logger.go b/logger.go index 754b7e9..52997a4 100644 --- a/logger.go +++ b/logger.go @@ -161,14 +161,14 @@ func (p parsedLog) entry() zapcore.Entry { level = zapcore.WarnLevel } file, rawLine, defined := bytes.Cut(p.location, []byte{':'}) - line, _ := strconv.ParseUint(string(rawLine), 10, 64) //nolint:errcheck + line, _ := strconv.Atoi(string(rawLine)) //nolint:errcheck return zapcore.Entry{ Level: level, // note: time.Now() is basically correct, and simpler than parsing. Time: time.Now().UTC(), LoggerName: string(p.loggerName), Message: string(p.tail), - Caller: zapcore.EntryCaller{Defined: defined, File: string(file), Line: int(line)}, + Caller: zapcore.EntryCaller{Defined: defined, File: string(file), Line: line}, } } @@ -181,6 +181,7 @@ func getIndexOrNil[T any](arr [][]T, index int) []T { } func parseLog(line []byte) *parsedLog { + line = bytes.TrimRight(line, "\r\n") tokens := bytes.SplitN(line, []byte{'\t'}, 5) parsed := &parsedLog{ date: getIndexOrNil(tokens, 0), diff --git a/manager.go b/manager.go index 4665aa7..a93a88b 100644 --- a/manager.go +++ b/manager.go @@ -10,6 +10,7 @@ import ( "path/filepath" "regexp" "runtime" + "runtime/debug" "strings" "sync" "time" @@ -62,7 +63,7 @@ func (m *Manager) LoadConfig(cfgPath string) error { m.connMu.Lock() defer m.connMu.Unlock() - m.logger.Debugw("loading", "config", cfgPath) + m.logger.Debugf("loading config: %s", cfgPath) //nolint:gosec b, err := os.ReadFile(cfgPath) if err != nil { @@ -105,12 +106,13 @@ func (m *Manager) CreateNetAppender() (*logging.NetAppender, error) { m.logger.Warn("m.netAppender already exists, replacing") } var err error - m.netAppender, err = logging.NewNetAppender(m.cloudConfig, nil, true) + m.netAppender, err = logging.NewNetAppender(m.cloudConfig, nil, true, m.logger) return m.netAppender, err } // StartSubsystem may be called early in startup when no cloud connectivity is configured. func (m *Manager) StartSubsystem(ctx context.Context, name string) error { + defer m.handlePanic() m.subsystemsMu.Lock() defer m.subsystemsMu.Unlock() @@ -146,6 +148,7 @@ func (m *Manager) SelfUpdate(ctx context.Context) (bool, error) { // SubsystemUpdates checks for updates to configured subsystems and restarts them as needed. func (m *Manager) SubsystemUpdates(ctx context.Context, cfg map[string]*pb.DeviceSubsystemConfig) { + defer m.handlePanic() if ctx.Err() != nil { return } @@ -178,6 +181,7 @@ func (m *Manager) SubsystemUpdates(ctx context.Context, cfg map[string]*pb.Devic // CheckUpdates retrieves an updated config from the cloud, and then passes it to SubsystemUpdates(). func (m *Manager) CheckUpdates(ctx context.Context) time.Duration { + defer m.handlePanic() m.logger.Debug("Checking cloud for update") cfg, interval, err := m.GetConfig(ctx) @@ -197,12 +201,14 @@ func (m *Manager) CheckUpdates(ctx context.Context) time.Duration { // SubsystemHealthChecks makes sure all subsystems are responding, and restarts them if not. func (m *Manager) SubsystemHealthChecks(ctx context.Context) { + defer m.handlePanic() if ctx.Err() != nil { return } m.logger.Debug("Starting health checks for all subsystems") m.subsystemsMu.Lock() defer m.subsystemsMu.Unlock() + for subsystemName, sub := range m.loadedSubsystems { if ctx.Err() != nil { return @@ -213,7 +219,7 @@ func (m *Manager) SubsystemHealthChecks(ctx context.Context) { if ctx.Err() != nil { return } - m.logger.Error(errw.Wrapf(err, "subsystem healthcheck failed for %s", subsystemName)) + m.logger.Error(errw.Wrapf(err, "Subsystem healthcheck failed for %s", subsystemName)) if err := sub.Stop(ctx); err != nil { m.logger.Error(errw.Wrapf(err, "stopping subsystem %s", subsystemName)) } @@ -223,6 +229,8 @@ func (m *Manager) SubsystemHealthChecks(ctx context.Context) { if err := sub.Start(ctx); err != nil && !errors.Is(err, ErrSubsystemDisabled) { m.logger.Error(errw.Wrapf(err, "restarting subsystem %s", subsystemName)) } + } else { + m.logger.Debugf("Subsystem healthcheck succeeded for %s", subsystemName) } } } @@ -305,7 +313,7 @@ func (m *Manager) LoadSubsystems(ctx context.Context) error { for _, name := range registry.List() { cfg, ok := cachedConfig[name] if !ok { - cfg = registry.GetDefaultConfig(name) + cfg = &pb.DeviceSubsystemConfig{} } err := m.loadSubsystem(ctx, name, cfg) if err != nil { @@ -440,8 +448,6 @@ func (m *Manager) GetConfig(ctx context.Context) (map[string]*pb.DeviceSubsystem return conf, minimalCheckInterval, err } - m.logger.Debugf("Cloud-provided config: %+v", resp) - err = m.saveCachedConfig(resp.GetSubsystemConfigs()) if err != nil { m.logger.Error(errw.Wrap(err, "saving agent config to cache")) @@ -507,3 +513,12 @@ func (m *Manager) getSubsystemVersions() map[string]string { } return vers } + +func (m *Manager) handlePanic() { + // if something panicked, log it and let things continue + r := recover() + if r != nil { + m.logger.Error("unknown panic encountered, will attempt to recover") + m.logger.Errorf("panic: %s\n%s", r, debug.Stack()) + } +} diff --git a/preinstall.sh b/preinstall.sh index 3d13cbd..ab2be81 100755 --- a/preinstall.sh +++ b/preinstall.sh @@ -90,7 +90,6 @@ check_fs() { create_tarball() { echo "Creating tarball for install." URL="https://storage.googleapis.com/packages.viam.com/apps/viam-agent/viam-agent-stable-$ARCH" - PROVISIONING_URL="https://storage.googleapis.com/packages.viam.com/apps/viam-agent-provisioning/viam-agent-provisioning-stable-$ARCH" TEMPDIR=$(mktemp -d) @@ -100,12 +99,10 @@ create_tarball() { mkdir -p "$TEMPDIR/opt/viam/cache" curl -fsSL "$URL" -o "$TEMPDIR/opt/viam/cache/viam-agent-factory-$ARCH" || return 1 - curl -fsSL "$PROVISIONING_URL" -o "$TEMPDIR/opt/viam/cache/viam-agent-provisioning-factory-$ARCH" || return 1 - chmod 755 "$TEMPDIR/opt/viam/cache/viam-agent-factory-$ARCH" "$TEMPDIR/opt/viam/cache/viam-agent-provisioning-factory-$ARCH" + chmod 755 "$TEMPDIR/opt/viam/cache/viam-agent-factory-$ARCH" mkdir -p "$TEMPDIR/opt/viam/bin" ln -s "/opt/viam/cache/viam-agent-factory-$ARCH" "$TEMPDIR/opt/viam/bin/viam-agent" - ln -s "/opt/viam/cache/viam-agent-provisioning-factory-$ARCH" "$TEMPDIR/opt/viam/bin/agent-provisioning" mkdir -p "$TEMPDIR/etc" if [ -f "$PROVISIONING_PATH" ]; then diff --git a/subsystems/provisioning/connstate.go b/subsystems/provisioning/connstate.go new file mode 100644 index 0000000..cc011a4 --- /dev/null +++ b/subsystems/provisioning/connstate.go @@ -0,0 +1,132 @@ +package provisioning + +import ( + "sync" + "time" + + "go.viam.com/rdk/logging" +) + +type connectionState struct { + mu sync.Mutex + + configured bool + + online bool + lastOnline time.Time + + connected bool + lastConnected time.Time + + provisioningMode bool + provisioningChange time.Time + + lastInteraction time.Time + + logger logging.Logger +} + +func NewConnectionState(logger logging.Logger) *connectionState { + return &connectionState{logger: logger} +} + +func (c *connectionState) setOnline(online bool) { + c.mu.Lock() + defer c.mu.Unlock() + + if c.online != online { + c.logger.Infof("Online: %t", online) + } + + c.online = online + if online { + c.lastOnline = time.Now() + } +} + +func (c *connectionState) getOnline() bool { + c.mu.Lock() + defer c.mu.Unlock() + return c.online +} + +func (c *connectionState) getLastOnline() time.Time { + c.mu.Lock() + defer c.mu.Unlock() + return c.lastOnline +} + +func (c *connectionState) setConnected(connected bool) { + c.mu.Lock() + defer c.mu.Unlock() + + if c.connected != connected { + c.logger.Infof("Wifi Connected: %t", connected) + } + + c.connected = connected + if connected { + c.lastConnected = time.Now() + } +} + +func (c *connectionState) getConnected() bool { + c.mu.Lock() + defer c.mu.Unlock() + return c.connected +} + +func (c *connectionState) getLastConnected() time.Time { + c.mu.Lock() + defer c.mu.Unlock() + return c.lastConnected +} + +func (c *connectionState) setConfigured(configured bool) { + c.mu.Lock() + defer c.mu.Unlock() + + if c.configured != configured { + c.logger.Infof("Viam Server Configured: %t", configured) + } + + c.configured = configured +} + +func (c *connectionState) getConfigured() bool { + c.mu.Lock() + defer c.mu.Unlock() + return c.configured +} + +func (c *connectionState) setProvisioning(mode bool) { + c.mu.Lock() + defer c.mu.Unlock() + c.provisioningMode = mode + c.provisioningChange = time.Now() +} + +// getProvisioning returns true if in provisioning mode, and the time of the last state change. +func (c *connectionState) getProvisioning() bool { + c.mu.Lock() + defer c.mu.Unlock() + return c.provisioningMode +} + +func (c *connectionState) getProvisioningChange() time.Time { + c.mu.Lock() + defer c.mu.Unlock() + return c.provisioningChange +} + +func (c *connectionState) setLastInteraction() { + c.mu.Lock() + defer c.mu.Unlock() + c.lastInteraction = time.Now() +} + +func (c *connectionState) getLastInteraction() time.Time { + c.mu.Lock() + defer c.mu.Unlock() + return c.lastInteraction +} diff --git a/subsystems/provisioning/definitions.go b/subsystems/provisioning/definitions.go new file mode 100644 index 0000000..4141f72 --- /dev/null +++ b/subsystems/provisioning/definitions.go @@ -0,0 +1,465 @@ +package provisioning + +import ( + "context" + "encoding/json" + "errors" + "io/fs" + "os" + "sync" + "time" + + gnm "github.com/Otterverse/gonetworkmanager/v2" + errw "github.com/pkg/errors" + agentpb "go.viam.com/api/app/agent/v1" + pb "go.viam.com/api/provisioning/v1" +) + +// This file contains type, const, and var definitions. + +const ( + SubsysName = "agent-provisioning" + + DNSMasqFilepath = "/etc/NetworkManager/dnsmasq-shared.d/80-viam.conf" + DNSMasqContentsRedirect = "address=/#/10.42.0.1\n" + DNSMasqContentsSetupOnly = "address=/.setup/10.42.0.1\n" + + PortalBindAddr = "10.42.0.1" + + ConnCheckFilepath = "/etc/NetworkManager/conf.d/80-viam.conf" + ConnCheckContents = "[connectivity]\nuri=http://packages.viam.com/check_network_status.txt\ninterval=300\n" + + NetworkTypeWifi = "wifi" + NetworkTypeWired = "wired" + NetworkTypeHotspot = "hotspot" + + IfNameAny = "any" + + HealthCheckTimeout = time.Minute +) + +var ( + DefaultConf = Config{ + Manufacturer: "viam", + Model: "custom", + FragmentID: "", + HotspotPrefix: "viam-setup", + HotspotPassword: "viamsetup", + DisableDNSRedirect: false, + RoamingMode: false, + OfflineTimeout: Timeout(time.Minute * 2), + UserTimeout: Timeout(time.Minute * 5), + FallbackTimeout: Timeout(time.Minute * 10), + Networks: []NetworkConfig{}, + } + + // Can be overwritten via cli arguments. + AppConfigFilePath = "/etc/viam.json" + ProvisioningConfigFilePath = "/etc/viam-provisioning.json" + + ErrBadPassword = errors.New("bad or missing password") + ErrConnCheckDisabled = errors.New("NetworkManager connectivity checking disabled by user, network management will be unavailable") + ErrNoActiveConnectionFound = errors.New("no active connection found") + scanLoopDelay = time.Second * 15 + connectTimeout = time.Second * 50 // longer than the 45 second timeout in NetworkManager +) + +type lockingNetwork struct { + mu sync.Mutex + network +} + +type network struct { + netType string + ssid string + security string + signal uint8 + priority int32 + isHotspot bool + + firstSeen time.Time + lastSeen time.Time + + lastTried time.Time + connected bool + lastConnected time.Time + lastError error + interfaceName string + + conn gnm.Connection +} + +func (n *network) getInfo() NetworkInfo { + var errStr string + if n.lastError != nil { + errStr = n.lastError.Error() + } + + return NetworkInfo{ + Type: n.netType, + SSID: n.ssid, + Security: n.security, + Signal: int32(n.signal), + Connected: n.connected, + LastError: errStr, + } +} + +type NetworkInfo struct { + Type string + SSID string + Security string + Signal int32 + Connected bool + LastError string +} + +func NetworkInfoToProto(net *NetworkInfo) *pb.NetworkInfo { + return &pb.NetworkInfo{ + Type: net.Type, + Ssid: net.SSID, + Security: net.Security, + Signal: net.Signal, + Connected: net.Connected, + LastError: net.LastError, + } +} + +func NetworkInfoFromProto(buf *pb.NetworkInfo) *NetworkInfo { + return &NetworkInfo{ + Type: buf.GetType(), + SSID: buf.GetSsid(), + Security: buf.GetSecurity(), + Signal: buf.GetSignal(), + Connected: buf.GetConnected(), + LastError: buf.GetLastError(), + } +} + +type NetworkConfig struct { + // "wifi", "wired", "wifi-static", "wired-static" + Type string `json:"type"` + + // name of interface, ex: "wlan0", "eth0", "enp14s0", etc. + Interface string `json:"interface"` + + // Wifi Settings + SSID string `json:"ssid"` + PSK string `json:"psk"` + + // Autoconnect Priority (primarily for wifi) + // higher values are preferred/tried first + // defaults to 0, but wifi networks added via hotspot are set to 999 when not in roaming mode + Priority int32 `json:"priority"` + + // CIDR format address, ex: 192.168.0.1/24 + // If unset, will default to "auto" (dhcp) + IPv4Address string `json:"ipv4_address"` + IPv4Gateway string `json:"ipv4_gateway"` + + // optional + IPv4DNS []string `json:"ipv4_dns"` + + // optional, 0 or -1 is default + // lower values are preferred (lower "cost") + // wired networks default to 100 + // wireless networks default to 600 + IPv4RouteMetric int64 `json:"ipv4_route_metric"` +} + +// MachineConfig represents the minimal needed for /etc/viam.json. +type MachineConfig struct { + Cloud *CloudConfig `json:"cloud"` +} + +type CloudConfig struct { + AppAddress string `json:"app_address"` + ID string `json:"id"` + Secret string `json:"secret"` +} + +func WriteDeviceConfig(file string, input userInput) error { + if input.RawConfig != "" { + return os.WriteFile(file, []byte(input.RawConfig), 0o600) + } + + cfg := &MachineConfig{ + Cloud: &CloudConfig{ + AppAddress: input.AppAddr, + ID: input.PartID, + Secret: input.Secret, + }, + } + + jsonBytes, err := json.Marshal(cfg) + if err != nil { + return err + } + return os.WriteFile(file, jsonBytes, 0o600) +} + +type portalData struct { + mu sync.Mutex + Updated time.Time + + inputChan chan<- userInput + + input *userInput + workers sync.WaitGroup + + // used to cancel background threads + cancel context.CancelFunc +} + +// must be called with p.mu already locked! +func (p *portalData) sendInput(connState *connectionState) { + input := *p.input + + // in case both network and device credentials are being updated + // only send user data if both are already set + if (input.SSID != "" && input.PartID != "") || + (input.SSID != "" && connState.getConfigured()) || + (input.PartID != "" && connState.getOnline()) { + p.input = &userInput{} + p.inputChan <- input + if p.cancel != nil { + p.cancel() + } + return + } + // if not, wait 10 seconds for full input + if p.cancel != nil { + p.cancel() + } + + ctx, cancel := context.WithCancel(context.Background()) + p.cancel = cancel + + p.workers.Add(1) + go func() { + defer p.workers.Done() + p.mu.Lock() + defer p.mu.Unlock() + select { + case <-ctx.Done(): + return + case <-time.After(time.Second * 10): + } + p.input = &userInput{} + p.inputChan <- input + }() +} + +type userInput struct { + // network + SSID string + PSK string + + // device credentials + PartID string + Secret string + AppAddr string + + // raw /etc/viam.json contents + RawConfig string +} + +func ConfigFromJSON(defaultConf Config, jsonBytes []byte) (*Config, error) { + minTimeout := Timeout(time.Second * 15) + conf := defaultConf + if err := json.Unmarshal(jsonBytes, &conf); err != nil { + return &defaultConf, err + } + + if conf.Manufacturer == "" || conf.Model == "" || conf.HotspotPrefix == "" || conf.HotspotPassword == "" { + return &defaultConf, errw.New("values in configs/attributes should not be empty, please omit empty fields entirely") + } + + var haveBadTimeout bool + if conf.OfflineTimeout < minTimeout { + conf.OfflineTimeout = defaultConf.OfflineTimeout + haveBadTimeout = true + } + + if conf.UserTimeout < minTimeout { + conf.UserTimeout = defaultConf.UserTimeout + haveBadTimeout = true + } + + if conf.FallbackTimeout < minTimeout { + conf.FallbackTimeout = defaultConf.FallbackTimeout + haveBadTimeout = true + } + + if haveBadTimeout { + return &conf, errw.Errorf("timeout values cannot be less than %s", time.Duration(minTimeout)) + } + + return &conf, nil +} + +func LoadConfig(updateConf *agentpb.DeviceSubsystemConfig) (*Config, error) { + newCfg := DefaultConf + cfg := &newCfg + + // config from disk (/etc/viam-provisioning.json) + jsonBytes, err := os.ReadFile(ProvisioningConfigFilePath) + if err != nil { + if !errors.Is(err, fs.ErrNotExist) { + return nil, err + } + } + if err == nil { + cfg, err = ConfigFromJSON(DefaultConf, jsonBytes) + if err != nil { + return cfg, errw.Wrap(err, "parsing viam-provisioning.json") + } + } + + // update with config from cloud (subsys attributes) + jsonBytes, err = updateConf.GetAttributes().MarshalJSON() + if err != nil { + return cfg, errw.Wrap(err, "marshaling JSON from attributes") + } + + cfg, err = ConfigFromJSON(*cfg, jsonBytes) + if err != nil { + return cfg, errw.Wrap(err, "parsing JSON from attributes") + } + + return cfg, nil +} + +// Config represents the json configurations parsed from either agent-provisioning.json OR passed from the "attributes" in the cloud config. +type Config struct { + // Things typically set in agent-provisioning.json + Manufacturer string `json:"manufacturer"` + Model string `json:"model"` + FragmentID string `json:"fragment_id"` + + // The interface to use for hotspot/provisioning/wifi management. Ex: "wlan0" + // Defaults to the first discovered 802.11 device + HotspotInterface string `json:"hotspot_interface"` + // The prefix to prepend to the hotspot name. + HotspotPrefix string `json:"hotspot_prefix"` + // Password required to connect to the hotspot. + HotspotPassword string `json:"hotspot_password"` + // If true, mobile (phone) users connecting to the hotspot won't be automatically redirected to the web portal. + DisableDNSRedirect bool `json:"disable_dns_redirect"` + + // How long without a connection before starting provisioning (hotspot) mode. + OfflineTimeout Timeout `json:"offline_timeout"` + + // How long since the last user interaction (via GRPC/app or web portal) before the state machine can resume. + UserTimeout Timeout `json:"user_timeout"` + + // If not "online", always drop out of hotspot mode and retry everything after this time limit. + FallbackTimeout Timeout `json:"fallback_timeout"` + + // When true, will try all known networks looking for internet (global) connectivity. + // Otherwise, will only try the primary wifi network and consider that sufficient if connected (regardless of global connectivity.) + RoamingMode bool `json:"roaming_mode"` + + // Additional networks to add/configure. Only useful in RoamingMode. + Networks []NetworkConfig `json:"networks"` + + // Computed from HotspotPrefix and Manufacturer + hotspotSSID string +} + +// Timeout allows parsing golang-style durations (1h20m30s) OR seconds-as-float from/to json. +type Timeout time.Duration + +func (t Timeout) MarshalJSON() ([]byte, error) { + return json.Marshal(time.Duration(t).String()) +} + +func (t *Timeout) UnmarshalJSON(b []byte) error { + var v any + if err := json.Unmarshal(b, &v); err != nil { + return err + } + switch value := v.(type) { + case float64: + *t = Timeout(value * float64(time.Second)) + return nil + case string: + tmp, err := time.ParseDuration(value) + if err != nil { + return err + } + *t = Timeout(tmp) + return nil + default: + return errw.Errorf("invalid duration: %+v", v) + } +} + +type health struct { + mu sync.Mutex + last time.Time +} + +func (h *health) MarkGood() { + h.mu.Lock() + defer h.mu.Unlock() + h.last = time.Now() +} + +func (h *health) Sleep(ctx context.Context, timeout time.Duration) bool { + select { + case <-ctx.Done(): + return false + case <-time.After(timeout): + h.mu.Lock() + defer h.mu.Unlock() + h.last = time.Now() + return true + } +} + +func (h *health) IsHealthy() bool { + h.mu.Lock() + defer h.mu.Unlock() + return time.Since(h.last) < HealthCheckTimeout +} + +type errorList struct { + mu sync.Mutex + errors []error +} + +func (e *errorList) Add(err ...error) { + e.mu.Lock() + defer e.mu.Unlock() + e.errors = append(e.errors, err...) +} + +func (e *errorList) Clear() { + e.mu.Lock() + defer e.mu.Unlock() + e.errors = []error{} +} + +func (e *errorList) Errors() []error { + e.mu.Lock() + defer e.mu.Unlock() + return e.errors +} + +type banner struct { + mu sync.Mutex + banner string +} + +func (b *banner) Set(banner string) { + b.mu.Lock() + defer b.mu.Unlock() + b.banner = banner +} + +func (b *banner) Get() string { + b.mu.Lock() + defer b.mu.Unlock() + return b.banner +} diff --git a/subsystems/provisioning/generators.go b/subsystems/provisioning/generators.go new file mode 100644 index 0000000..46a1a2a --- /dev/null +++ b/subsystems/provisioning/generators.go @@ -0,0 +1,188 @@ +package provisioning + +import ( + "encoding/binary" + "fmt" + "net" + "regexp" + "strconv" + "strings" + + gnm "github.com/Otterverse/gonetworkmanager/v2" + "github.com/google/uuid" + errw "github.com/pkg/errors" +) + +// This file contains the wifi/hotspot setting generation functions. + +func generateHotspotSettings(id, ssid, psk, ifName string) gnm.ConnectionSettings { + IPAsUint32, err := generateAddress(PortalBindAddr) + if err != nil { + // BindAddr is a const, so should only ever fail if code itself is changed/broken + panic(err) + } + + settings := gnm.ConnectionSettings{ + "connection": map[string]any{ + "id": id, + "uuid": uuid.New().String(), + "type": "802-11-wireless", + "autoconnect": false, + "interface-name": ifName, + }, + "802-11-wireless": map[string]any{ + "mode": "ap", + "ssid": []byte(ssid), + }, + "802-11-wireless-security": map[string]any{ + "key-mgmt": "wpa-psk", + "psk": psk, + }, + "ipv4": map[string]any{ + "method": "shared", + "addresses": [][]uint32{{IPAsUint32, 24, IPAsUint32}}, + }, + "ipv6": map[string]any{ + "method": "disabled", + }, + } + return settings +} + +func generateNetworkSettings(id string, cfg NetworkConfig) (gnm.ConnectionSettings, error) { + settings := gnm.ConnectionSettings{} + if id == "" { + return nil, errw.New("id cannot be empty") + } + + var netType string + switch cfg.Type { + case NetworkTypeWifi: + netType = "802-11-wireless" + case NetworkTypeWired: + netType = "802-3-ethernet" + default: + return nil, errw.Errorf("unknown network type: %s", cfg.Type) + } + + settings["connection"] = map[string]any{ + "id": id, + "uuid": uuid.New().String(), + "type": netType, + "autoconnect": true, + "autoconnect-priority": cfg.Priority, + } + + if cfg.Interface != "" { + settings["connection"]["interface-name"] = cfg.Interface + } + + // Handle Wifi + if cfg.Type == NetworkTypeWifi { + settings["802-11-wireless"] = map[string]any{ + "mode": "infrastructure", + "ssid": []byte(cfg.SSID), + } + if cfg.PSK != "" { + settings["802-11-wireless-security"] = map[string]any{"key-mgmt": "wpa-psk", "psk": cfg.PSK} + } + } + + // Handle IP Config + ip4, err := generateIPv4Settings(cfg) + if err != nil { + return settings, err + } + settings["ipv4"] = ip4 + + return settings, nil +} + +func generateIPv4Settings(cfg NetworkConfig) (map[string]any, error) { + // -1 is special for "automatic" + if cfg.IPv4RouteMetric == 0 { + cfg.IPv4RouteMetric = -1 + } + + if cfg.IPv4Address == "" { + return map[string]any{"method": "auto", "route-metric": cfg.IPv4RouteMetric}, nil + } + + // CIDR format, ex: 192.168.0.1/24 + ip4Regex := regexp.MustCompile(`^([0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3})/?([0-9]{1,2})?$`) + ret := ip4Regex.FindStringSubmatch(cfg.IPv4Address) + if len(ret) != 3 { + return nil, errw.Errorf("invalid ipv4 address: %s", cfg.IPv4Address) + } + + ip, err := generateAddress(ret[1]) + if err != nil { + return nil, err + } + + gateway, err := generateAddress(cfg.IPv4Gateway) + if err != nil { + return nil, err + } + + mask, err := strconv.ParseUint(ret[2], 10, 32) + if err != nil { + return nil, errw.Wrapf(err, "parsing ipv4 netmask: %s", cfg.IPv4Address) + } + + ip4 := map[string]any{ + "method": "manual", + "addresses": [][]uint32{{ip, uint32(mask), gateway}}, //nolint:gosec + "route-metric": cfg.IPv4RouteMetric, + } + + if len(cfg.IPv4DNS) > 0 { + var dnsData []uint32 + for _, dns := range cfg.IPv4DNS { + dnsInt, err := generateAddress(dns) + if err != nil { + return nil, errw.Errorf("error parsing DNS ipv4 address: %s", dns) + } + dnsData = append(dnsData, dnsInt) + } + ip4["dns"] = dnsData + } + + return ip4, nil +} + +// converts an ipv4 string (192.168.0.1) to a uint32 in network byte order. +func generateAddress(addr string) (uint32, error) { + parseErr := errw.Errorf("parsing ipv4: %s", addr) + // double-check with another library for correctness + if net.ParseIP(addr) == nil { + return 0, parseErr + } + + ret := strings.Split(addr, ".") + if len(ret) != 4 { + return 0, parseErr + } + + var outBytes []byte + for _, nibble := range ret { + b, err := strconv.ParseUint(nibble, 10, 8) + if err != nil { + return 0, parseErr + } + outBytes = append(outBytes, byte(b)) + } + + return binary.LittleEndian.Uint32(outBytes), nil +} + +func genNetKey(ifName, ssid string) string { + if ifName == "" { + ifName = IfNameAny + } + + if ssid == "" { + return ifName + } + return fmt.Sprintf("%s@%s", ssid, ifName) +} diff --git a/subsystems/provisioning/grpc.go b/subsystems/provisioning/grpc.go new file mode 100644 index 0000000..d408cb1 --- /dev/null +++ b/subsystems/provisioning/grpc.go @@ -0,0 +1,142 @@ +package provisioning + +import ( + "context" + "errors" + "fmt" + "net" + "time" + + errw "github.com/pkg/errors" + pb "go.viam.com/api/provisioning/v1" + "google.golang.org/grpc" +) + +func (w *Provisioning) startGRPC() error { + bind := PortalBindAddr + ":4772" + lis, err := net.Listen("tcp", bind) + if err != nil { + return errw.Wrapf(err, "error listening on: %s", bind) + } + + w.grpcServer = grpc.NewServer(grpc.WaitForHandlers(true)) + pb.RegisterProvisioningServiceServer(w.grpcServer, w) + + w.portalData.workers.Add(1) + go func() { + defer w.portalData.workers.Done() + if err := w.grpcServer.Serve(lis); err != nil { + w.logger.Error(err) + } + }() + return nil +} + +func (w *Provisioning) GetSmartMachineStatus(ctx context.Context, + req *pb.GetSmartMachineStatusRequest, +) (*pb.GetSmartMachineStatusResponse, error) { + w.connState.setLastInteraction() + + ret := &pb.GetSmartMachineStatusResponse{ + ProvisioningInfo: &pb.ProvisioningInfo{ + FragmentId: w.cfg.FragmentID, + Model: w.cfg.Model, + Manufacturer: w.cfg.Manufacturer, + }, + HasSmartMachineCredentials: w.connState.getConfigured(), + IsOnline: w.connState.getOnline(), + Errors: w.errListAsStrings(), + } + + lastSSID := w.netState.LastSSID(w.Config().HotspotInterface) + if lastSSID != "" { + lastNetwork := w.netState.Network(w.Config().HotspotInterface, lastSSID) + lastNetworkInfo := lastNetwork.getInfo() + ret.LatestConnectionAttempt = NetworkInfoToProto(&lastNetworkInfo) + } + + // reset the errors, as they were now just displayed + w.errors.Clear() + + return ret, nil +} + +func (w *Provisioning) SetNetworkCredentials(ctx context.Context, + req *pb.SetNetworkCredentialsRequest, +) (*pb.SetNetworkCredentialsResponse, error) { + w.connState.setLastInteraction() + + if req.GetType() != NetworkTypeWifi { + return nil, errw.Errorf("unknown network type: %s, only %s currently supported", req.GetType(), NetworkTypeWifi) + } + + w.portalData.mu.Lock() + defer w.portalData.mu.Unlock() + + w.portalData.Updated = time.Now() + w.portalData.input.SSID = req.GetSsid() + w.portalData.input.PSK = req.GetPsk() + + lastSSID := w.netState.LastSSID(w.Config().HotspotInterface) + if req.GetSsid() == lastSSID && lastSSID != "" { + lastNetwork := w.netState.LockingNetwork(w.Config().HotspotInterface, lastSSID) + lastNetwork.mu.Lock() + lastNetwork.lastError = nil + lastNetwork.mu.Unlock() + } + + w.portalData.sendInput(w.connState) + + return &pb.SetNetworkCredentialsResponse{}, nil +} + +func (w *Provisioning) SetSmartMachineCredentials(ctx context.Context, + req *pb.SetSmartMachineCredentialsRequest, +) (*pb.SetSmartMachineCredentialsResponse, error) { + w.connState.setLastInteraction() + + cloud := req.GetCloud() + if cloud == nil { + return nil, errors.New("request must include a Cloud config section") + } + w.portalData.mu.Lock() + defer w.portalData.mu.Unlock() + w.portalData.Updated = time.Now() + w.portalData.input.PartID = cloud.GetId() + w.portalData.input.Secret = cloud.GetSecret() + w.portalData.input.AppAddr = cloud.GetAppAddress() + + w.portalData.sendInput(w.connState) + + return &pb.SetSmartMachineCredentialsResponse{}, nil +} + +func (w *Provisioning) GetNetworkList(ctx context.Context, + req *pb.GetNetworkListRequest, +) (*pb.GetNetworkListResponse, error) { + w.connState.setLastInteraction() + + visibleNetworks := w.getVisibleNetworks() + + networks := make([]*pb.NetworkInfo, len(visibleNetworks)) + for i, net := range visibleNetworks { + networks[i] = NetworkInfoToProto(&net) + } + + return &pb.GetNetworkListResponse{Networks: networks}, nil +} + +func (w *Provisioning) errListAsStrings() []string { + errList := []string{} + + lastNetwork := w.netState.Network(w.Config().HotspotInterface, w.netState.LastSSID(w.Config().HotspotInterface)) + + if lastNetwork.lastError != nil { + errList = append(errList, fmt.Sprintf("SSID: %s: %s", lastNetwork.ssid, lastNetwork.lastError)) + } + + for _, err := range w.errors.Errors() { + errList = append(errList, err.Error()) + } + return errList +} diff --git a/subsystems/provisioning/networkmanager.go b/subsystems/provisioning/networkmanager.go new file mode 100644 index 0000000..6155927 --- /dev/null +++ b/subsystems/provisioning/networkmanager.go @@ -0,0 +1,752 @@ +package provisioning + +import ( + "context" + "errors" + "os" + "reflect" + "sort" + "time" + + gnm "github.com/Otterverse/gonetworkmanager/v2" + errw "github.com/pkg/errors" +) + +func (w *Provisioning) warnIfMultiplePrimaryNetworks() { + if w.cfg.RoamingMode { + return + } + var primaryCandidates []string + highestPriority := int32(-999) + for _, nw := range w.netState.Networks() { + if nw.conn == nil || nw.isHotspot || nw.netType != NetworkTypeWifi || + (nw.interfaceName != "" && nw.interfaceName != w.Config().HotspotInterface) { + continue + } + + if nw.priority > highestPriority { + highestPriority = nw.priority + primaryCandidates = []string{nw.ssid} + } else if nw.priority == highestPriority { + primaryCandidates = append(primaryCandidates, nw.ssid) + } + } + if len(primaryCandidates) > 1 { + w.logger.Warnf( + "Multiple networks %s tied for highest priority (%d), selection will be arbitrary. Consider using Roaming Mode.", + primaryCandidates, + highestPriority, + ) + } +} + +func (w *Provisioning) getVisibleNetworks() []NetworkInfo { + var visible []NetworkInfo + for _, nw := range w.netState.Networks() { + if nw.lastSeen.After(time.Now().Add(time.Minute*-1)) && !nw.isHotspot { + visible = append(visible, nw.getInfo()) + } + } + + // sort by strongest signal + sort.SliceStable(visible, func(i, j int) bool { + return visible[i].Signal > visible[j].Signal + }) + + return visible +} + +func (w *Provisioning) getLastNetworkTried() NetworkInfo { + lastNetwork := w.netState.LastNetwork(w.Config().HotspotInterface) + return lastNetwork.getInfo() +} + +func (w *Provisioning) checkOnline(force bool) error { + if force { + if err := w.nm.CheckConnectivity(); err != nil { + w.logger.Error(err) + } + } + + state, err := w.nm.State() + if err != nil { + return err + } + + var online bool + + //nolint:exhaustive + switch state { + case gnm.NmStateConnectedGlobal: + online = true + case gnm.NmStateConnectedSite: + fallthrough + case gnm.NmStateConnectedLocal: + // do nothing, but may need these two in the future + case gnm.NmStateUnknown: + err = errors.New("unable to determine network state") + default: + err = nil + } + + w.connState.setOnline(online) + return err +} + +func (w *Provisioning) checkConnections() error { + var connected bool + defer func() { + w.connState.setConnected(connected) + }() + + for ifName, dev := range w.netState.Devices() { + activeConnection, err := dev.GetPropertyActiveConnection() + if err != nil { + return err + } + if activeConnection == nil { + w.netState.SetActiveConn(ifName, nil) + w.netState.SetActiveSSID(ifName, "") + continue + } + + connection, err := activeConnection.GetPropertyConnection() + if err != nil { + return err + } + + settings, err := connection.GetSettings() + if err != nil { + return err + } + + connIfName, ssid, _ := getIfNameSSIDTypeFromSettings(settings) + nw := w.netState.LockingNetwork(connIfName, ssid) + + state, err := activeConnection.GetPropertyState() + nw.mu.Lock() + if err != nil { + w.logger.Error(errw.Wrapf(err, "getting state of active connection: %s", genNetKey(ifName, ssid))) + w.netState.SetActiveConn(ifName, nil) + w.netState.SetActiveSSID(ifName, "") + nw.connected = false + } else { + w.netState.SetActiveConn(ifName, activeConnection) + w.netState.SetActiveSSID(ifName, ssid) + nw.connected = true + } + nw.mu.Unlock() + + // if this isn't the primary wifi device, we're done + if ifName != w.Config().HotspotInterface { + continue + } + + // in roaming mode, we don't care WHAT network is connected + if w.cfg.RoamingMode && state == gnm.NmActiveConnectionStateActivated && ssid != w.Config().hotspotSSID { + connected = true + } + + // in normal (single) mode, we need to be connected to the primary (highest priority) network + if !w.cfg.RoamingMode && state == gnm.NmActiveConnectionStateActivated && ssid == w.netState.PrimarySSID(w.Config().HotspotInterface) { + connected = true + } + } + + return nil +} + +// StartProvisioning puts the wifi in hotspot mode and starts a captive portal. +func (w *Provisioning) StartProvisioning(ctx context.Context, inputChan chan<- userInput) error { + if w.connState.getProvisioning() { + return errors.New("provisioning mode already started") + } + + w.opMu.Lock() + defer w.opMu.Unlock() + + w.logger.Info("Starting provisioning mode.") + _, err := w.addOrUpdateConnection(NetworkConfig{ + Type: NetworkTypeHotspot, + Interface: w.Config().HotspotInterface, + SSID: w.Config().hotspotSSID, + }) + if err != nil { + return err + } + if err := w.activateConnection(ctx, w.Config().HotspotInterface, w.Config().hotspotSSID); err != nil { + return errw.Wrap(err, "error starting provisioning mode hotspot") + } + + // start portal with ssid list and known connections + if err := w.startPortal(inputChan); err != nil { + err = errors.Join(err, w.deactivateConnection(w.Config().HotspotInterface, w.Config().hotspotSSID)) + return errw.Wrap(err, "could not start web/grpc portal") + } + + w.connState.setProvisioning(true) + return nil +} + +func (w *Provisioning) StopProvisioning() error { + w.opMu.Lock() + defer w.opMu.Unlock() + return w.stopProvisioning() +} + +func (w *Provisioning) stopProvisioning() error { + w.logger.Info("Stopping provisioning mode.") + w.connState.setProvisioning(false) + err := w.stopPortal() + err2 := w.deactivateConnection(w.Config().HotspotInterface, w.Config().hotspotSSID) + if errors.Is(err2, ErrNoActiveConnectionFound) { + return err + } + return errors.Join(err, err2) +} + +func (w *Provisioning) ActivateConnection(ctx context.Context, ifName, ssid string) error { + if w.connState.getProvisioning() && ifName == w.Config().HotspotInterface { + return errors.New("cannot activate another connection while in provisioning mode") + } + + w.opMu.Lock() + defer w.opMu.Unlock() + return w.activateConnection(ctx, ifName, ssid) +} + +func (w *Provisioning) activateConnection(ctx context.Context, ifName, ssid string) error { + now := time.Now() + nw := w.netState.LockingNetwork(ifName, ssid) + nw.mu.Lock() + defer nw.mu.Unlock() + + if nw.conn == nil && ssid != "" { + nw = w.netState.LockingNetwork(IfNameAny, ssid) + nw.mu.Lock() + defer nw.mu.Unlock() + if nw.conn == nil { + return errw.Errorf("no settings found for network: %s", genNetKey(ifName, ssid)) + } + } + + w.logger.Infof("Activating connection: %s", genNetKey(ifName, ssid)) + + var netDev gnm.Device + if nw.netType == NetworkTypeWifi || nw.netType == NetworkTypeHotspot { + // wifi + if nw.netType != NetworkTypeHotspot { + nw.lastTried = now + w.netState.SetLastSSID(ifName, ssid) + } + netDev = w.netState.WifiDevice(ifName) + } else { + // wired + nw.lastTried = now + netDev = w.netState.EthDevice(ifName) + } + + if netDev == nil { + return errw.Errorf("cannot activate connection due to missing interface: %s", ifName) + } + + activeConnection, err := w.nm.ActivateConnection(nw.conn, netDev, nil) + if err != nil { + nw.lastError = err + return errw.Wrapf(err, "activating connection: %s", genNetKey(ifName, ssid)) + } + + if err := w.waitForConnect(ctx, netDev); err != nil { + nw.lastError = err + nw.connected = false + return err + } + + nw.connected = true + nw.lastConnected = now + nw.lastError = nil + w.netState.SetActiveConn(ifName, activeConnection) + + w.logger.Infof("Successfully activated connection: %s", genNetKey(ifName, ssid)) + + if nw.netType != NetworkTypeHotspot { + w.netState.SetActiveSSID(ifName, ssid) + if ifName == w.Config().HotspotInterface && (w.cfg.RoamingMode || w.netState.PrimarySSID(ifName) == ssid) { + w.connState.setConnected(true) + } + return w.checkOnline(true) + } + + return nil +} + +func (w *Provisioning) DeactivateConnection(ifName, ssid string) error { + if w.connState.getProvisioning() && ifName == w.Config().HotspotInterface { + return errors.New("cannot deactivate another connection while in provisioning mode") + } + + w.opMu.Lock() + defer w.opMu.Unlock() + return w.deactivateConnection(ifName, ssid) +} + +func (w *Provisioning) deactivateConnection(ifName, ssid string) error { + activeConn := w.netState.ActiveConn(ifName) + if activeConn == nil { + return errw.Wrapf(ErrNoActiveConnectionFound, "interface: %s", ifName) + } + + nw := w.netState.LockingNetwork(ifName, ssid) + nw.mu.Lock() + defer nw.mu.Unlock() + + w.logger.Infof("Deactivating connection: %s", genNetKey(ifName, ssid)) + + if err := w.nm.DeactivateConnection(activeConn); err != nil { + nw.lastError = err + return errw.Wrapf(err, "deactivating connection: %s", genNetKey(ifName, ssid)) + } + + w.logger.Infof("Successfully deactivated connection: %s", genNetKey(ifName, ssid)) + + if ifName == w.Config().HotspotInterface { + w.connState.setConnected(false) + } + + nw.connected = false + nw.lastConnected = time.Now() + nw.lastError = nil + w.netState.SetActiveSSID(ifName, "") + return nil +} + +func (w *Provisioning) waitForConnect(ctx context.Context, device gnm.Device) error { + timeoutCtx, cancel := context.WithTimeout(ctx, connectTimeout) + defer cancel() + + changeChan := make(chan gnm.DeviceStateChange, 32) + exitChan := make(chan struct{}) + defer close(exitChan) + + if err := device.SubscribeState(changeChan, exitChan); err != nil { + return errw.Wrap(err, "monitoring connection activation") + } + + for { + select { + case update := <-changeChan: + w.logger.Debugf("%s->%s (%s)", update.OldState, update.NewState, update.Reason) + //nolint:exhaustive + switch update.NewState { + case gnm.NmDeviceStateActivated: + return nil + case gnm.NmDeviceStateFailed: + if update.Reason == gnm.NmDeviceStateReasonNoSecrets { + return ErrBadPassword + } + // custom error if it's some other reason for failure + return errw.Errorf("connection failed: %s", update.Reason) + default: + } + default: + if !w.mainLoopHealth.Sleep(timeoutCtx, time.Second) { + return errw.Wrap(ctx.Err(), "waiting for network activation") + } + } + } +} + +func (w *Provisioning) AddOrUpdateConnection(cfg NetworkConfig) (bool, error) { + w.opMu.Lock() + defer w.opMu.Unlock() + return w.addOrUpdateConnection(cfg) +} + +// returns true if network was new (added) and not updated. +func (w *Provisioning) addOrUpdateConnection(cfg NetworkConfig) (bool, error) { + var changesMade bool + + if cfg.Type != NetworkTypeWifi && cfg.Type != NetworkTypeHotspot && cfg.Type != NetworkTypeWired { + return changesMade, errw.Errorf("unspported network type %s, only %s and %s currently supported", + cfg.Type, NetworkTypeWifi, NetworkTypeWired) + } + + if cfg.Type != NetworkTypeWired && cfg.PSK != "" && len(cfg.PSK) < 8 { + return changesMade, errors.New("wifi passwords must be at least 8 characters long, or completely empty (for unsecured networks)") + } + + netKey := genNetKey(cfg.Interface, cfg.SSID) + nw := w.netState.LockingNetwork(cfg.Interface, cfg.SSID) + nw.lastTried = time.Time{} + nw.priority = cfg.Priority + + var settings gnm.ConnectionSettings + var err error + if cfg.Type == NetworkTypeHotspot { + if cfg.SSID != w.Config().hotspotSSID { + return changesMade, errw.Errorf("only the builtin provisioning hotspot may use the %s network type", NetworkTypeHotspot) + } + nw.isHotspot = true + settings = generateHotspotSettings(w.cfg.HotspotPrefix, w.Config().hotspotSSID, w.cfg.HotspotPassword, w.Config().HotspotInterface) + } else { + settings, err = generateNetworkSettings(w.cfg.Manufacturer+"-"+netKey, cfg) + if err != nil { + return changesMade, err + } + } + + if cfg.Type == NetworkTypeWifi && !w.cfg.RoamingMode && cfg.Priority == 999 { + // lower the priority of any existing/prior primary network + w.lowerMaxNetPriorities(cfg.SSID) + w.netState.SetPrimarySSID(w.Config().HotspotInterface, cfg.SSID) + } + + w.logger.Infof("Adding/updating settings for network %s", netKey) + + var oldSettings gnm.ConnectionSettings + if nw.conn != nil { + oldSettings, err = nw.conn.GetSettings() + if err != nil { + return changesMade, errw.Wrapf(err, "getting current settings for %s", netKey) + } + + if err := nw.conn.Update(settings); err != nil { + // we may be out of sync with NetworkManager + nw.conn = nil + w.logger.Warn(errw.Wrapf(err, "updating settings for %s, attempting to add as new network", netKey)) + } + } + + if nw.conn == nil { + changesMade = true + newConn, err := w.settings.AddConnection(settings) + if err != nil { + return changesMade, errw.Wrap(err, "adding new connection") + } + nw.conn = newConn + return changesMade, nil + } + + newSettings, err := nw.conn.GetSettings() + if err != nil { + return changesMade, errw.Wrapf(err, "getting new settings for %s", netKey) + } + + changesMade = !reflect.DeepEqual(oldSettings, newSettings) || changesMade + + return changesMade, nil +} + +// this doesn't error as it's not technically fatal if it fails. +func (w *Provisioning) lowerMaxNetPriorities(skip string) { + for _, nw := range w.netState.LockingNetworks() { + netKey := genNetKey(nw.interfaceName, nw.ssid) + if netKey == skip || netKey == genNetKey(w.Config().HotspotInterface, w.Config().hotspotSSID) || nw.priority < 999 || + nw.netType != NetworkTypeWifi || (nw.interfaceName != "" && nw.interfaceName != w.Config().HotspotInterface) { + continue + } + + nw.mu.Lock() + if nw.conn != nil { + settings, err := nw.conn.GetSettings() + if err != nil { + nw.conn = nil + w.logger.Warnf("error (%s) encountered when getting settings for %s", err, nw.ssid) + nw.mu.Unlock() + continue + } + + if getPriorityFromSettings(settings) == 999 { + settings["connection"]["autoconnect-priority"] = 998 + + // deprecated fields that are read-only, so can't try to set them + delete(settings["ipv6"], "addresses") + delete(settings["ipv6"], "routes") + + w.logger.Debugf("Lowering priority of %s to 998", netKey) + + if err := nw.conn.Update(settings); err != nil { + nw.conn = nil + w.logger.Warnf("error (%s) encountered when updating settings for %s", err, netKey) + } + } + nw.priority = getPriorityFromSettings(settings) + } + nw.mu.Unlock() + } +} + +func (w *Provisioning) checkConfigured() { + _, err := os.ReadFile(w.AppCfgPath) + w.connState.setConfigured(err == nil) +} + +// tryCandidates returns true if a network activated. +func (w *Provisioning) tryCandidates(ctx context.Context) bool { + for _, ssid := range w.getCandidates(w.Config().HotspotInterface) { + err := w.ActivateConnection(ctx, w.Config().HotspotInterface, ssid) + if err != nil { + w.logger.Error(err) + continue + } + + // in single mode we just need a connection + if !w.cfg.RoamingMode { + return true + } + + // in roaming mode we need full internet + if w.connState.getOnline() { + return true + } + + w.logger.Warnf("SSID %s connected, but does not provide internet access.", ssid) + } + return false +} + +func (w *Provisioning) getCandidates(ifName string) []string { + var candidates []network + for _, nw := range w.netState.Networks() { + if nw.netType != NetworkTypeWifi || (nw.interfaceName != "" && nw.interfaceName != ifName) { + continue + } + // ssid seen within the past minute + visible := nw.lastSeen.After(time.Now().Add(time.Minute * -1)) + + // ssid has a connection known to network manager + configured := nw.conn != nil + + // firstSeen is reset if a network disappears for more than a minute, so retry if it comes back (or generally after 10 minutes) + recentlyTried := nw.lastTried.After(nw.firstSeen) && nw.lastTried.After(time.Now().Add(time.Duration(w.cfg.FallbackTimeout)*-1)) + + if !nw.isHotspot && visible && configured && !recentlyTried { + candidates = append(candidates, nw) + } + } + + if !w.cfg.RoamingMode { + for _, nw := range candidates { + if nw.ssid == w.netState.PrimarySSID(w.Config().HotspotInterface) { + return []string{nw.ssid} + } + } + return []string{} + } + + // sort by priority + sort.SliceStable(candidates, func(i, j int) bool { return candidates[i].priority > candidates[j].priority }) + + var out []string + for _, nw := range candidates { + out = append(out, nw.ssid) + } + + return out +} + +func (w *Provisioning) backgroundLoop(ctx context.Context, scanChan chan<- bool) { + defer w.monitorWorkers.Done() + w.logger.Info("Background state monitors started") + defer w.logger.Info("Background state monitors stopped") + for { + if !w.bgLoopHealth.Sleep(ctx, scanLoopDelay) { + return + } + + w.checkConfigured() + if err := w.networkScan(ctx); err != nil { + w.logger.Error(err) + } + if err := w.checkConnections(); err != nil { + w.logger.Error(err) + } + if err := w.checkOnline(false); err != nil { + w.logger.Error(err) + } + scanChan <- true + } +} + +func (w *Provisioning) mainLoop(ctx context.Context) { + defer w.monitorWorkers.Done() + + scanChan := make(chan bool, 16) + inputChan := make(chan userInput, 1) + + w.monitorWorkers.Add(1) + go w.backgroundLoop(ctx, scanChan) + + for { + var userInputReceived bool + + select { + case <-ctx.Done(): + return + case userInput := <-inputChan: + if userInput.RawConfig != "" || userInput.PartID != "" { + w.logger.Info("Device config received") + err := WriteDeviceConfig(w.AppCfgPath, userInput) + if err != nil { + w.errors.Add(err) + w.logger.Error(err) + continue + } + w.checkConfigured() + userInputReceived = true + } + + var newSSID string + var changesMade bool + if userInput.SSID != "" { + w.logger.Infof("Wifi settings received for %s", userInput.SSID) + priority := int32(999) + if w.cfg.RoamingMode { + priority = 100 + } + cfg := NetworkConfig{ + Type: NetworkTypeWifi, + SSID: userInput.SSID, + PSK: userInput.PSK, + Priority: priority, + Interface: w.Config().HotspotInterface, + } + var err error + changesMade, err = w.AddOrUpdateConnection(cfg) + if err != nil { + w.errors.Add(err) + w.logger.Error(err) + continue + } + userInputReceived = true + newSSID = cfg.SSID + } + + // wait 3 seconds so responses can be sent to/seen by user + if !w.mainLoopHealth.Sleep(ctx, time.Second*3) { + return + } + if changesMade { + err := w.StopProvisioning() + if err != nil { + w.logger.Error(err) + continue + } + err = w.ActivateConnection(ctx, w.Config().HotspotInterface, newSSID) + if err != nil { + w.logger.Error(err) + continue + } + if !w.connState.getOnline() { + err := w.deactivateConnection(w.Config().HotspotInterface, newSSID) + if err != nil { + w.logger.Error(err) + } + nw := w.netState.LockingNetwork("", newSSID) + nw.mu.Lock() + if nw.conn != nil { + // add a user warning for the portal + err = errw.New("Network has no internet. Resubmit to use anyway.") + nw.lastError = err + w.logger.Warn(err) + } else { + w.logger.Error("cannot find %s in network list", genNetKey("", newSSID)) + } + nw.mu.Unlock() + err = w.StartProvisioning(ctx, inputChan) + if err != nil { + w.logger.Error(err) + } + } + } + case <-scanChan: + case <-time.After(scanLoopDelay * 4): + // safety fallback if something hangs + w.logger.Warn("wifi scan has not completed for %s", scanLoopDelay*5) + } + + w.mainLoopHealth.MarkGood() + + isOnline := w.connState.getOnline() + lastOnline := w.connState.getLastOnline() + isConnected := w.connState.getConnected() + lastConnected := w.connState.getLastConnected() + hasConnectivity := isConnected || isOnline + lastConnectivity := lastConnected + if lastOnline.After(lastConnected) { + lastConnectivity = lastOnline + } + isConfigured := w.connState.getConfigured() + allGood := isConfigured && (isConnected || isOnline) + if w.cfg.RoamingMode { + allGood = isOnline && isConfigured + hasConnectivity = isOnline + lastConnectivity = lastOnline + } + pMode := w.connState.getProvisioning() + pModeChange := w.connState.getProvisioningChange() + now := time.Now() + + w.logger.Debugf("wifi: %t (%s), internet: %t, config present: %t", + isConnected, + genNetKey(w.Config().HotspotInterface, w.netState.ActiveSSID(w.Config().HotspotInterface)), + isOnline, + isConfigured, + ) + + if pMode { + // complex logic, so wasting some variables for readability + + // portal interaction time is updated when a user loads a page or makes a grpc request + inactivePortal := w.connState.getLastInteraction().Before(now.Add(time.Duration(w.cfg.UserTimeout)*-1)) || userInputReceived + + // exit/retry to test networks only if there's no recent user interaction AND configuration is present + haveCandidates := len(w.getCandidates(w.Config().HotspotInterface)) > 0 && inactivePortal && isConfigured + + // exit/retry every FallbackTimeout (10 minute default), unless user is active + fallbackHit := pModeChange.Before(now.Add(time.Duration(w.cfg.FallbackTimeout)*-1)) && inactivePortal + + shouldExit := allGood || haveCandidates || fallbackHit + + w.logger.Debugf("inactive portal: %t, have candidates: %t, fallback timeout: %t", inactivePortal, haveCandidates, fallbackHit) + + if shouldExit { + if err := w.StopProvisioning(); err != nil { + w.logger.Error(err) + } else { + pMode = w.connState.getProvisioning() + } + } + } + + if allGood || pMode { + continue + } + + // not in provisioning mode + if !hasConnectivity { + if w.tryCandidates(ctx) { + hasConnectivity = w.connState.getConnected() || w.connState.getOnline() + // if we're roaming or this network was JUST added, it must have internet + if w.cfg.RoamingMode { + hasConnectivity = w.connState.getOnline() + } + if hasConnectivity { + continue + } + lastConnectivity = w.connState.getLastConnected() + if w.cfg.RoamingMode { + lastConnectivity = w.connState.getLastOnline() + } + } + } + + // not in provisioning mode, so start it if not configured (/etc/viam.json) + // OR as long as we've been offline for at least OfflineTimeout (2 minute default) + if !isConfigured || lastConnectivity.Before(now.Add(time.Duration(w.cfg.OfflineTimeout)*-1)) { + if err := w.StartProvisioning(ctx, inputChan); err != nil { + w.logger.Error(err) + } + } + } +} diff --git a/subsystems/provisioning/networkstate.go b/subsystems/provisioning/networkstate.go new file mode 100644 index 0000000..c2a8cd4 --- /dev/null +++ b/subsystems/provisioning/networkstate.go @@ -0,0 +1,257 @@ +package provisioning + +import ( + "sync" + + gnm "github.com/Otterverse/gonetworkmanager/v2" + "go.viam.com/rdk/logging" +) + +type networkState struct { + mu sync.RWMutex + logger logging.Logger + + // key is ssid@interface for wifi, ex: TestNetwork@wlan0 + // interface may be "any" for no interface set, ex: TestNetwork@any + // wired networks are just interface, ex: eth0 + // generate with genNetKey(ifname, ssid) + network map[string]*lockingNetwork + + // key is interface name, ex: wlan0 + primarySSID map[string]string + activeSSID map[string]string + lastSSID map[string]string + activeConn map[string]gnm.ActiveConnection + ethDevice map[string]gnm.DeviceWired + wifiDevice map[string]gnm.DeviceWireless +} + +func NewNetworkState(logger logging.Logger) *networkState { + return &networkState{ + logger: logger, + network: make(map[string]*lockingNetwork), + activeSSID: make(map[string]string), + primarySSID: make(map[string]string), + lastSSID: make(map[string]string), + ethDevice: make(map[string]gnm.DeviceWired), + wifiDevice: make(map[string]gnm.DeviceWireless), + activeConn: make(map[string]gnm.ActiveConnection), + } +} + +// LockingNetwork returns a pointer to a network, wrapped in a lockable struct, so updates are persisted +// Users must lock the returned network before updates. +func (n *networkState) LockingNetwork(iface, ssid string) *lockingNetwork { + n.mu.Lock() + defer n.mu.Unlock() + + id := genNetKey(iface, ssid) + + net, ok := n.network[id] + if !ok { + net = &lockingNetwork{} + n.network[id] = net + if ssid != "" { + net.ssid = ssid + net.netType = NetworkTypeWifi + } else { + net.netType = NetworkTypeWired + } + if iface != IfNameAny && iface != "" { + net.interfaceName = iface + } + n.logger.Debugf("found new network %s (%s)", id, net.netType) + } + + return net +} + +// Network returns a copy-by-value of a network, which should be considered read-only, and doesn't need locking. +func (n *networkState) Network(iface, ssid string) network { + n.mu.Lock() + defer n.mu.Unlock() + id := genNetKey(iface, ssid) + ln, ok := n.network[id] + if !ok { + return network{} + } + ln.mu.Lock() + defer ln.mu.Unlock() + return ln.network +} + +func (n *networkState) SetNetwork(iface, ssid string, net network) { + ln := n.LockingNetwork(iface, ssid) + ln.mu.Lock() + ln.network = net + ln.mu.Unlock() +} + +func (n *networkState) LockingNetworks() []*lockingNetwork { + n.mu.RLock() + defer n.mu.RUnlock() + + nets := []*lockingNetwork{} + + for _, net := range n.network { + nets = append(nets, net) + } + + return nets +} + +func (n *networkState) Networks() []network { + n.mu.RLock() + defer n.mu.RUnlock() + + nets := []network{} + + for _, net := range n.network { + nets = append(nets, net.network) + } + + return nets +} + +func (n *networkState) LastNetwork(iface string) network { + return n.Network(iface, n.LastSSID(iface)) +} + +func (n *networkState) PrimarySSID(iface string) string { + n.mu.RLock() + defer n.mu.RUnlock() + + ssid, ok := n.primarySSID[iface] + if !ok { + n.logger.Warnf("cannot find primary SSID for %s", iface) + return "" + } + + return ssid +} + +func (n *networkState) SetPrimarySSID(iface, ssid string) { + n.mu.Lock() + defer n.mu.Unlock() + + if ssid == "" { + delete(n.primarySSID, iface) + return + } + n.primarySSID[iface] = ssid +} + +func (n *networkState) ActiveSSID(iface string) string { + n.mu.RLock() + defer n.mu.RUnlock() + return n.activeSSID[iface] +} + +func (n *networkState) SetActiveSSID(iface, ssid string) { + n.mu.Lock() + defer n.mu.Unlock() + + if ssid == "" { + delete(n.activeSSID, iface) + return + } + n.activeSSID[iface] = ssid +} + +func (n *networkState) LastSSID(iface string) string { + n.mu.RLock() + defer n.mu.RUnlock() + + ssid, ok := n.lastSSID[iface] + if !ok { + return "" + } + + return ssid +} + +func (n *networkState) SetLastSSID(iface, ssid string) { + n.mu.Lock() + defer n.mu.Unlock() + + n.lastSSID[iface] = ssid +} + +func (n *networkState) ActiveConn(iface string) gnm.ActiveConnection { + n.mu.RLock() + defer n.mu.RUnlock() + + conn, ok := n.activeConn[iface] + if !ok { + n.logger.Errorf("cannot find active connection for %s", iface) + return nil + } + + return conn +} + +func (n *networkState) SetActiveConn(iface string, conn gnm.ActiveConnection) { + n.mu.Lock() + defer n.mu.Unlock() + + if conn == nil { + delete(n.activeConn, iface) + return + } + n.activeConn[iface] = conn +} + +func (n *networkState) EthDevice(iface string) gnm.DeviceWired { + n.mu.RLock() + defer n.mu.RUnlock() + + dev, ok := n.ethDevice[iface] + if !ok { + n.logger.Errorf("cannot find eth device for %s", iface) + return nil + } + + return dev +} + +func (n *networkState) SetEthDevice(iface string, dev gnm.DeviceWired) { + n.mu.Lock() + defer n.mu.Unlock() + + n.ethDevice[iface] = dev +} + +func (n *networkState) WifiDevice(iface string) gnm.DeviceWireless { + n.mu.RLock() + defer n.mu.RUnlock() + + dev, ok := n.wifiDevice[iface] + if !ok { + n.logger.Errorf("cannot find wifi device for %s", iface) + return nil + } + + return dev +} + +func (n *networkState) SetWifiDevice(iface string, dev gnm.DeviceWireless) { + n.mu.Lock() + defer n.mu.Unlock() + + n.wifiDevice[iface] = dev +} + +func (n *networkState) Devices() map[string]gnm.Device { + n.mu.Lock() + defer n.mu.Unlock() + + // merge the two device types into a single generic list + allDevices := make(map[string]gnm.Device) + for ifName, dev := range n.wifiDevice { + allDevices[ifName] = dev + } + for ifName, dev := range n.ethDevice { + allDevices[ifName] = dev + } + return allDevices +} diff --git a/subsystems/provisioning/portal.go b/subsystems/provisioning/portal.go new file mode 100644 index 0000000..996b552 --- /dev/null +++ b/subsystems/provisioning/portal.go @@ -0,0 +1,203 @@ +package provisioning + +import ( + "embed" + "encoding/json" + "errors" + "html/template" + "net" + "net/http" + "os" + "time" + + errw "github.com/pkg/errors" +) + +type templateData struct { + Manufacturer string + Model string + FragmentID string + + Banner string + LastNetwork NetworkInfo + VisibleSSIDs []NetworkInfo + Errors []string + IsConfigured bool + IsOnline bool +} + +//go:embed templates/* +var templates embed.FS + +func (w *Provisioning) startPortal(inputChan chan<- userInput) error { + w.dataMu.Lock() + defer w.dataMu.Unlock() + w.portalData = &portalData{input: &userInput{}, inputChan: inputChan} + + if err := w.startGRPC(); err != nil { + return errw.Wrap(err, "error starting GRPC service") + } + + if err := w.startWeb(); err != nil { + return errw.Wrap(err, "error starting web portal service") + } + + return nil +} + +func (w *Provisioning) startWeb() error { + mux := http.NewServeMux() + mux.HandleFunc("/", w.portalIndex) + mux.HandleFunc("/save", w.portalSave) + w.webServer = &http.Server{ + Handler: mux, + ReadTimeout: time.Second * 10, + } + bind := PortalBindAddr + ":80" + lis, err := net.Listen("tcp", bind) + if err != nil { + return errw.Wrapf(err, "error listening on: %s", bind) + } + + w.portalData.workers.Add(1) + go func() { + defer w.portalData.workers.Done() + err := w.webServer.Serve(lis) + if !errors.Is(err, http.ErrServerClosed) { + w.logger.Error(err) + } + }() + return nil +} + +func (w *Provisioning) stopPortal() error { + if w.grpcServer != nil { + w.grpcServer.Stop() + w.grpcServer = nil + } + + var err error + if w.webServer != nil { + err = w.webServer.Close() + } + + w.portalData.mu.Lock() + defer w.portalData.mu.Unlock() + if w.portalData.cancel != nil { + w.portalData.cancel() + } + w.portalData.workers.Wait() + w.portalData = &portalData{input: &userInput{}} + + return err +} + +func (w *Provisioning) portalIndex(resp http.ResponseWriter, req *http.Request) { + defer func() { + if err := req.Body.Close(); err != nil { + w.logger.Error(err) + } + }() + w.connState.setLastInteraction() + + cfg := w.Config() + + data := templateData{ + Manufacturer: cfg.Manufacturer, + Model: cfg.Model, + FragmentID: cfg.FragmentID, + Banner: w.banner.Get(), + LastNetwork: w.getLastNetworkTried(), + VisibleSSIDs: w.getVisibleNetworks(), + IsOnline: w.connState.getOnline(), + IsConfigured: w.connState.getConfigured(), + Errors: w.errListAsStrings(), + } + + t, err := template.ParseFS(templates, "templates/*.html") + if err != nil { + w.logger.Error(err) + http.Error(resp, err.Error(), http.StatusInternalServerError) + } + + if os.Getenv("VIAM_AGENT_DEVMODE") != "" { + w.logger.Warn("devmode enabled, using templates from /opt/viam/tmp/templates/") + newT, err := template.ParseGlob("/opt/viam/tmp/templates/*.html") + if err == nil { + t = newT + } + } + + err = t.Execute(resp, data) + if err != nil { + w.logger.Error(err) + http.Error(resp, err.Error(), http.StatusInternalServerError) + } + + // reset the errors and banner, as they were now just displayed + w.banner.Set("") + w.errors.Clear() +} + +func (w *Provisioning) portalSave(resp http.ResponseWriter, req *http.Request) { + defer func() { + if err := req.Body.Close(); err != nil { + w.logger.Error(err) + } + }() + defer http.Redirect(resp, req, "/", http.StatusSeeOther) + + if req.Method != http.MethodPost { + return + } + + w.connState.setLastInteraction() + + ssid := req.FormValue("ssid") + psk := req.FormValue("password") + rawConfig := req.FormValue("viamconfig") + + if ssid == "" && !w.connState.getOnline() { + w.errors.Add(errors.New("no SSID provided")) + return + } + + if rawConfig == "" && !w.connState.getConfigured() { + w.errors.Add(errors.New("no device config provided")) + return + } + + w.portalData.mu.Lock() + defer w.portalData.mu.Unlock() + if rawConfig != "" { + // we'll check if the config is valid, but NOT use the parsed config, in case additional fields are in the json + cfg := &MachineConfig{} + if err := json.Unmarshal([]byte(rawConfig), cfg); err != nil { + w.errors.Add(errw.Wrap(err, "invalid json config contents")) + return + } + if cfg.Cloud.ID == "" || cfg.Cloud.Secret == "" || cfg.Cloud.AppAddress == "" { + w.errors.Add(errors.New("incomplete cloud config provided")) + return + } + w.portalData.input.RawConfig = rawConfig + w.logger.Debug("saving raw device config") + w.banner.Set("Saving device config. ") + } + + if ssid != "" { + w.portalData.input.SSID = ssid + w.portalData.input.PSK = psk + w.logger.Debugf("saving credentials for %s", w.portalData.input.SSID) + w.banner.Set(w.banner.Get() + "Added credentials for SSID: " + w.portalData.input.SSID) + } + + if ssid == w.netState.LastSSID(w.Config().HotspotInterface) && ssid != "" { + lastNetwork := w.netState.LockingNetwork(w.Config().HotspotInterface, ssid) + lastNetwork.mu.Lock() + lastNetwork.lastError = nil + lastNetwork.mu.Unlock() + } + w.portalData.Updated = time.Now() + w.portalData.sendInput(w.connState) +} diff --git a/subsystems/provisioning/provisioning.go b/subsystems/provisioning/provisioning.go index dbd62e2..5f9b317 100644 --- a/subsystems/provisioning/provisioning.go +++ b/subsystems/provisioning/provisioning.go @@ -1,41 +1,314 @@ -// Package provisioning contains the provisioning agent subsystem. +// Package provisioning is the subsystem responsible for network/wifi management, and initial device setup via hotspot. package provisioning import ( "context" + "net/http" + "reflect" + "strings" + "sync" + "time" + gnm "github.com/Otterverse/gonetworkmanager/v2" + errw "github.com/pkg/errors" "github.com/viamrobotics/agent" "github.com/viamrobotics/agent/subsystems" "github.com/viamrobotics/agent/subsystems/registry" - pb "go.viam.com/api/app/agent/v1" + agentpb "go.viam.com/api/app/agent/v1" + pb "go.viam.com/api/provisioning/v1" "go.viam.com/rdk/logging" + "google.golang.org/grpc" ) func init() { - registry.Register(SubsysName, NewSubsystem, DefaultConfig) + registry.Register(SubsysName, NewProvisioning) } -var ( - Debug = false - DefaultConfig = &pb.DeviceSubsystemConfig{} - AppConfigFilePath = "/etc/viam.json" -) +type Provisioning struct { + monitorWorkers sync.WaitGroup -const ( - SubsysName = "agent-provisioning" -) + // blocks start/stop/etc operations + // holders of this lock must use HealthySleep to respond to HealthChecks from the parent agent during long operations + opMu sync.Mutex + running bool + disabled bool + + // used to stop main/bg loops + cancel context.CancelFunc + + // only set during NewProvisioning, no lock + nm gnm.NetworkManager + settings gnm.Settings + hostname string + logger logging.Logger + AppCfgPath string + + // internal locking + connState *connectionState + netState *networkState + errors *errorList + banner *banner + + mainLoopHealth *health + bgLoopHealth *health + + // locking for config updates + dataMu sync.Mutex + cfg *Config + + // portal + webServer *http.Server + grpcServer *grpc.Server + portalData *portalData + + pb.UnimplementedProvisioningServiceServer +} + +func NewProvisioning(ctx context.Context, logger logging.Logger, updateConf *agentpb.DeviceSubsystemConfig) (subsystems.Subsystem, error) { + cfg, err := LoadConfig(updateConf) + if err != nil { + logger.Error(errw.Wrap(err, "loading provisioning config")) + } + logger.Debugf("Provisioning Config: %+v", cfg) + + w := &Provisioning{ + disabled: updateConf.GetDisable(), + cfg: cfg, + AppCfgPath: AppConfigFilePath, + logger: logger, + + connState: NewConnectionState(logger), + netState: NewNetworkState(logger), + + errors: &errorList{}, + banner: &banner{}, + portalData: &portalData{}, + + mainLoopHealth: &health{}, + bgLoopHealth: &health{}, + } + return w, nil +} + +func (w *Provisioning) init(ctx context.Context) error { + w.mainLoopHealth.MarkGood() + w.bgLoopHealth.MarkGood() + + nm, err := gnm.NewNetworkManager() + if err != nil { + return err + } + + settings, err := gnm.NewSettings() + if err != nil { + return err + } + + w.nm = nm + w.settings = settings + + w.hostname, err = settings.GetPropertyHostname() + if err != nil { + return errw.Wrap(err, "error getting hostname from NetworkManager, is NetworkManager installed and enabled?") + } + + w.updateHotspotSSID(w.cfg) + + if err := w.writeDNSMasq(); err != nil { + return errw.Wrap(err, "error writing dnsmasq configuration") + } + + if err := w.testConnCheck(); err != nil { + return err + } + + if err := w.initDevices(); err != nil { + return err + } + + w.checkConfigured() + if err := w.networkScan(ctx); err != nil { + return err + } + + w.warnIfMultiplePrimaryNetworks() + + if w.cfg.RoamingMode { + w.logger.Info("Roaming Mode enabled. Will try all connections for global internet connectivity.") + } else { + w.logger.Infof("Default (Single Network) Mode enabled. Will directly connect only to primary network: %s", + w.netState.PrimarySSID(w.Config().HotspotInterface)) + } + + if err := w.checkConnections(); err != nil { + return err + } + + // Is there a configured wifi network? If so, set last times to now so we use normal timeouts. + // Otherwise, hotspot will start immediately if not connected, while wifi network might still be booting. + for _, nw := range w.netState.Networks() { + if nw.conn != nil && nw.netType == NetworkTypeWifi && (nw.interfaceName == "" || nw.interfaceName == w.Config().HotspotInterface) { + w.connState.lastConnected = time.Now() + w.connState.lastOnline = time.Now() + break + } + } + + return nil +} + +func (w *Provisioning) Start(ctx context.Context) error { + w.opMu.Lock() + defer w.opMu.Unlock() + if w.running { + return nil + } + + if w.disabled { + w.logger.Infof("agent-provisioning disabled") + return agent.ErrSubsystemDisabled + } -func NewSubsystem(ctx context.Context, logger logging.Logger, updateConf *pb.DeviceSubsystemConfig) (subsystems.Subsystem, error) { - extraArgs := []string{ - "--app-config", AppConfigFilePath, - "--provisioning-config", "/etc/viam-provisioning.json", + if w.nm == nil || w.settings == nil { + if err := w.init(ctx); err != nil { + return err + } } - if Debug { - extraArgs = append(extraArgs, "--debug") + + w.processAdditionalnetworks(ctx) + + if err := w.checkOnline(true); err != nil { + w.logger.Error(err) + } + + cancelCtx, cancel := context.WithCancel(ctx) + w.cancel = cancel + + // This will loop indefinitely until context cancellation or serious error + w.monitorWorkers.Add(1) + go w.mainLoop(cancelCtx) + + w.logger.Info("agent-provisioning startup complete") + w.running = true + return nil +} + +func (w *Provisioning) Stop(ctx context.Context) error { + w.opMu.Lock() + defer w.opMu.Unlock() + if !w.running { + return nil + } + + w.logger.Infof("%s subsystem exiting", SubsysName) + if w.connState.getProvisioning() { + err := w.stopProvisioning() + if err != nil { + w.logger.Error(err) + } } - is, err := agent.NewInternalSubsystem(SubsysName, extraArgs, logger, true) + if w.cancel != nil { + w.cancel() + } + w.monitorWorkers.Wait() + w.running = false + return nil +} + +// Update validates and/or updates a subsystem, returns true if subsystem should be restarted. +func (w *Provisioning) Update(ctx context.Context, updateConf *agentpb.DeviceSubsystemConfig) (bool, error) { + w.opMu.Lock() + defer w.opMu.Unlock() + + var needRestart bool + + if w.disabled != updateConf.GetDisable() { + w.disabled = updateConf.GetDisable() + needRestart = true + } + + if w.disabled { + return needRestart, nil + } + + if w.nm == nil || w.settings == nil { + if err := w.init(ctx); err != nil { + return true, err + } + } + + cfg, err := LoadConfig(updateConf) if err != nil { - return nil, err + return needRestart, err + } + + w.updateHotspotSSID(cfg) + if cfg.HotspotInterface == "" { + cfg.HotspotInterface = w.Config().HotspotInterface + } + + if reflect.DeepEqual(cfg, w.cfg) { + return needRestart, nil + } + + needRestart = true + w.logger.Debugf("Updated config differs from previous. Previous: %+v New: %+v", w.cfg, cfg) + + w.dataMu.Lock() + defer w.dataMu.Unlock() + w.cfg = cfg + + return needRestart, nil +} + +// HealthCheck reports if a subsystem is running correctly (it is restarted if not). +func (w *Provisioning) HealthCheck(ctx context.Context) error { + w.opMu.Lock() + defer w.opMu.Unlock() + if w.disabled { + return nil + } + + if w.bgLoopHealth.IsHealthy() && w.mainLoopHealth.IsHealthy() { + return nil + } + + return errw.New("provisioning not responsive") +} + +// Version returns the current version of the subsystem. +func (w *Provisioning) Version() string { + return agent.GetRevision() +} + +func (w *Provisioning) Config() Config { + w.dataMu.Lock() + defer w.dataMu.Unlock() + return *w.cfg +} + +func (w *Provisioning) processAdditionalnetworks(ctx context.Context) { + if !w.cfg.RoamingMode && len(w.cfg.Networks) > 0 { + w.logger.Warn("Additional networks configured, but Roaming Mode is not enabled. Additional wifi networks will likely be unused.") + } + + for _, network := range w.cfg.Networks { + _, err := w.AddOrUpdateConnection(network) + if err != nil { + w.logger.Error(errw.Wrapf(err, "error adding network %s", network.SSID)) + } + if network.Interface != "" && w.Config().HotspotInterface != network.Interface { + if err := w.ActivateConnection(ctx, network.Interface, network.SSID); err != nil { + w.logger.Error(err) + } + } + } +} + +// must be run inside dataMu lock. +func (w *Provisioning) updateHotspotSSID(cfg *Config) { + cfg.hotspotSSID = cfg.HotspotPrefix + "-" + strings.ToLower(w.hostname) + if len(cfg.hotspotSSID) > 32 { + cfg.hotspotSSID = cfg.hotspotSSID[:32] } - return agent.NewAgentSubsystem(ctx, SubsysName, logger, is) } diff --git a/subsystems/provisioning/scanning.go b/subsystems/provisioning/scanning.go new file mode 100644 index 0000000..91db9f1 --- /dev/null +++ b/subsystems/provisioning/scanning.go @@ -0,0 +1,288 @@ +package provisioning + +// This file includes functions used for wifi scans. + +import ( + "context" + "strings" + "time" + + gnm "github.com/Otterverse/gonetworkmanager/v2" + errw "github.com/pkg/errors" +) + +func (w *Provisioning) networkScan(ctx context.Context) error { + wifiDev := w.netState.WifiDevice(w.Config().HotspotInterface) + if wifiDev == nil { + return errw.Errorf("cannot find hotspot interface: %s", w.Config().HotspotInterface) + } + + prevScan, err := wifiDev.GetPropertyLastScan() + if err != nil { + return errw.Wrap(err, "error scanning wifi") + } + + err = wifiDev.RequestScan() + if err != nil { + return errw.Wrap(err, "scanning wifi") + } + + var lastScan int64 + for { + lastScan, err = wifiDev.GetPropertyLastScan() + if err != nil { + return errw.Wrap(err, "scanning wifi") + } + if lastScan > prevScan { + break + } + if !w.bgLoopHealth.Sleep(ctx, time.Second) { + return nil + } + } + + wifiList, err := wifiDev.GetAccessPoints() + if err != nil { + return errw.Wrap(err, "scanning wifi") + } + + // set "now" to be reusable for consistency + now := time.Now() + for _, ap := range wifiList { + if ctx.Err() != nil { + return nil //nolint:nilerr + } + ssid, err := ap.GetPropertySSID() + if err != nil { + w.logger.Warn(errw.Wrap(err, "scanning wifi")) + continue + } + + if ssid == "" { + w.logger.Debug("wifi network with blank ssid, ignoring") + continue + } + + signal, err := ap.GetPropertyStrength() + if err != nil { + w.logger.Warn(errw.Wrap(err, "scanning wifi")) + continue + } + + apFlags, err := ap.GetPropertyFlags() + if err != nil { + w.logger.Warn(errw.Wrap(err, "scanning wifi")) + continue + } + + wpaFlags, err := ap.GetPropertyWPAFlags() + if err != nil { + w.logger.Warn(errw.Wrap(err, "scanning wifi")) + continue + } + + rsnFlags, err := ap.GetPropertyRSNFlags() + if err != nil { + w.logger.Warn(errw.Wrap(err, "scanning wifi")) + continue + } + + nw := w.netState.LockingNetwork(w.Config().HotspotInterface, ssid) + nw.mu.Lock() + + nw.netType = NetworkTypeWifi + nw.ssid = ssid + nw.security = parseWPAFlags(apFlags, wpaFlags, rsnFlags) + nw.signal = signal + nw.lastSeen = now + + if nw.firstSeen.IsZero() { + nw.firstSeen = now + } + + nw.mu.Unlock() + } + + for _, nw := range w.netState.LockingNetworks() { + if ctx.Err() != nil { + return nil //nolint:nilerr + } + nw.mu.Lock() + // if a network isn't visible, reset the firstSeen time + if nw.lastSeen.Before(time.Now().Add(time.Minute * -1)) { + nw.firstSeen = time.Time{} + } + nw.mu.Unlock() + } + + return w.updateKnownConnections(ctx) +} + +func parseWPAFlags(apFlags, wpaFlags, rsnFlags uint32) string { + flags := []string{} + if apFlags&uint32(gnm.Nm80211APFlagsPrivacy) != 0 && wpaFlags == uint32(gnm.Nm80211APSecNone) && rsnFlags == uint32(gnm.Nm80211APSecNone) { + return "WEP" + } + + if wpaFlags == uint32(gnm.Nm80211APSecNone) && rsnFlags == uint32(gnm.Nm80211APSecNone) { + return "-" + } + + if wpaFlags != uint32(gnm.Nm80211APSecNone) { + flags = append(flags, "WPA1") + } + if rsnFlags&uint32(gnm.Nm80211APSecKeyMgmtPSK) != 0 || rsnFlags&uint32(gnm.Nm80211APSecKeyMgmt8021X) != 0 { + flags = append(flags, "WPA2") + } + if rsnFlags&uint32(gnm.Nm80211APSecKeyMgmtSAE) != 0 { + flags = append(flags, "WPA3") + } + if rsnFlags&uint32(gnm.Nm80211APSecKeyMgmtOWE) != 0 { + flags = append(flags, "OWE") + } else if rsnFlags&uint32(gnm.Nm80211APSecKeyMgmtOWETM) != 0 { + flags = append(flags, "OWE-TM") + } + if wpaFlags&uint32(gnm.Nm80211APSecKeyMgmt8021X) != 0 || rsnFlags&uint32(gnm.Nm80211APSecKeyMgmt8021X) != 0 { + flags = append(flags, "802.1X") + } + + return strings.Join(flags, " ") +} + +// updates connections/settings from those known to NetworkManager. +func (w *Provisioning) updateKnownConnections(ctx context.Context) error { + conns, err := w.settings.ListConnections() + if err != nil { + return err + } + + highestPriority := make(map[string]int32) + for _, conn := range conns { + //nolint:nilerr + if ctx.Err() != nil { + return nil + } + settings, err := conn.GetSettings() + if err != nil { + return err + } + + ifName, ssid, netType := getIfNameSSIDTypeFromSettings(settings) + if ifName == "" { + // unknown network type, or broken network + continue + } + + _, ok := highestPriority[ifName] + if !ok { + highestPriority[ifName] = -999 + } + + if netType != NetworkTypeWired && ssid == "" { + w.logger.Warn("wifi network with no ssid detected, skipping") + continue + } + + // actually record the network + nw := w.netState.LockingNetwork(ifName, ssid) + nw.mu.Lock() + nw.netType = netType + nw.conn = conn + nw.priority = getPriorityFromSettings(settings) + + if nw.ssid == w.Config().hotspotSSID { + nw.netType = NetworkTypeHotspot + nw.isHotspot = true + } else if nw.priority > highestPriority[ifName] { + highestPriority[ifName] = nw.priority + w.netState.SetPrimarySSID(ifName, nw.ssid) + } + nw.mu.Unlock() + } + + return nil +} + +func getPriorityFromSettings(settings gnm.ConnectionSettings) int32 { + connection, ok := settings["connection"] + if !ok { + return 0 + } + + priRaw, ok := connection["autoconnect-priority"] + if !ok { + return 0 + } + + priority, ok := priRaw.(int32) + if !ok { + return 0 + } + return priority +} + +func getSSIDFromSettings(settings gnm.ConnectionSettings) string { + // gnm.ConnectionSettings is a map[string]map[string]interface{} + wifi, ok := settings["802-11-wireless"] + if !ok { + return "" + } + + modeRaw, ok := wifi["mode"] + if !ok { + return "" + } + + mode, ok := modeRaw.(string) + // we'll take hotspots and "normal" infrastructure connections only + if !ok || !(mode == "infrastructure" || mode == "ap") { + return "" + } + + ssidRaw, ok := wifi["ssid"] + if !ok { + return "" + } + ssidBytes, ok := ssidRaw.([]byte) + if !ok { + return "" + } + if len(ssidBytes) == 0 { + return "" + } + return string(ssidBytes) +} + +func getIfNameSSIDTypeFromSettings(settings gnm.ConnectionSettings) (string, string, string) { + _, wired := settings["802-3-ethernet"] + _, wireless := settings["802-11-wireless"] + if !wired && !wireless { + return "", "", "" + } + + var ifName string + conn, ok := settings["connection"] + if ok { + ifKey, ok := conn["interface-name"] + if ok { + name, ok := ifKey.(string) + if ok { + ifName = name + } + } + } + + if wired { + return ifName, "", NetworkTypeWired + } + + if wireless { + ssid := getSSIDFromSettings(settings) + if ssid == "" { + return "", "", "" + } + return ifName, ssid, NetworkTypeWifi + } + + return "", "", "" +} diff --git a/subsystems/provisioning/setup.go b/subsystems/provisioning/setup.go new file mode 100644 index 0000000..cb5974a --- /dev/null +++ b/subsystems/provisioning/setup.go @@ -0,0 +1,141 @@ +package provisioning + +// This file includes functions used only once during startup in NewNMWrapper() + +import ( + "bytes" + "errors" + "io/fs" + "os" + + gnm "github.com/Otterverse/gonetworkmanager/v2" + errw "github.com/pkg/errors" +) + +func (w *Provisioning) writeDNSMasq() error { + DNSMasqContents := DNSMasqContentsRedirect + if w.cfg.DisableDNSRedirect { + DNSMasqContents = DNSMasqContentsSetupOnly + } + + fileBytes, err := os.ReadFile(DNSMasqFilepath) + if err == nil && bytes.Equal(fileBytes, []byte(DNSMasqContents)) { + return nil + } + + if err != nil && !errors.Is(err, fs.ErrNotExist) { + return err + } + //nolint:gosec + return os.WriteFile(DNSMasqFilepath, []byte(DNSMasqContents), 0o644) +} + +func (w *Provisioning) testConnCheck() error { + connCheckEnabled, err := w.nm.GetPropertyConnectivityCheckEnabled() + if err != nil { + return errw.Wrap(err, "error getting NetworkManager connectivity check state") + } + + if !connCheckEnabled { + hasConnCheck, err := w.nm.GetPropertyConnectivityCheckAvailable() + if err != nil { + return errw.Wrap(err, "error getting NetworkManager connectivity check configuration") + } + + if !hasConnCheck { + if err := w.writeConnCheck(); err != nil { + return (errw.Wrap(err, "error writing NetworkManager connectivity check configuration")) + } + if err := w.nm.Reload(0); err != nil { + return (errw.Wrap(err, "error reloading NetworkManager")) + } + + hasConnCheck, err = w.nm.GetPropertyConnectivityCheckAvailable() + if err != nil { + return errw.Wrap(err, "error getting NetworkManager connectivity check configuration") + } + if !hasConnCheck { + return errors.New("error configuring NetworkManager connectivity check") + } + } + + connCheckEnabled, err = w.nm.GetPropertyConnectivityCheckEnabled() + if err != nil { + return errw.Wrap(err, "error getting NetworkManager connectivity check state") + } + + if !connCheckEnabled { + return ErrConnCheckDisabled + } + } + return nil +} + +func (w *Provisioning) writeConnCheck() error { + fileBytes, err := os.ReadFile(ConnCheckFilepath) + if err == nil && bytes.Equal(fileBytes, []byte(ConnCheckContents)) { + return nil + } + + if err != nil && !errors.Is(err, fs.ErrNotExist) { + return err + } + //nolint:gosec + return os.WriteFile(ConnCheckFilepath, []byte(ConnCheckContents), 0o644) +} + +// must be run inside dataMu lock. +func (w *Provisioning) initDevices() error { + devices, err := w.nm.GetDevices() + if err != nil { + return err + } + + for _, device := range devices { + devType, err := device.GetPropertyDeviceType() + if err != nil { + return err + } + + //nolint:exhaustive + switch devType { + case gnm.NmDeviceTypeEthernet: + ethDev, ok := device.(gnm.DeviceWired) + if !ok { + return errors.New("cannot cast to wired device") + } + ifName, err := ethDev.GetPropertyInterface() + if err != nil { + return err + } + w.netState.SetEthDevice(ifName, ethDev) + case gnm.NmDeviceTypeWifi: + wifiDev, ok := device.(gnm.DeviceWireless) + if !ok { + return errors.New("cannot cast to wifi device") + } + ifName, err := wifiDev.GetPropertyInterface() + if err != nil { + return err + } + w.netState.SetWifiDevice(ifName, wifiDev) + + if w.cfg.HotspotInterface == "" || ifName == w.cfg.HotspotInterface { + w.cfg.HotspotInterface = ifName + w.logger.Infof("Using %s for hotspot/provisioning, will actively manage wifi only on this device.", ifName) + } + default: + continue + } + + if err := device.SetPropertyAutoConnect(true); err != nil { + return err + } + } + + if w.cfg.HotspotInterface == "" { + return errors.New("cannot find wifi device for provisioning/hotspot") + } + + return nil +} diff --git a/subsystems/provisioning/templates/base.html b/subsystems/provisioning/templates/base.html new file mode 100644 index 0000000..7a85bd3 --- /dev/null +++ b/subsystems/provisioning/templates/base.html @@ -0,0 +1,152 @@ + + + + + + Connect to your Wifi + + {{ template "scripts" }} + + +{{ template "body" . }} + + diff --git a/subsystems/provisioning/templates/index.html b/subsystems/provisioning/templates/index.html new file mode 100644 index 0000000..e8ce03b --- /dev/null +++ b/subsystems/provisioning/templates/index.html @@ -0,0 +1,72 @@ +{{define "scripts"}}{{end}} +{{define "body"}} + +
+ {{if eq (len .VisibleSSIDs) 0}} +
+ × + We were unable to automatically find available Wifi networks to connect to. Please refresh or manually enter the SSID below. +
+ {{end}} + {{if .Banner}} +
+ {{.Banner}} +
+ {{end}} + + {{range .Errors}} +
+ × + Error: {{.}} +
+ {{end}} + +

Smart Machine Setup

+ +
+
Device Info
+
Manufacturer:
{{.Manufacturer}}
+
Model:
{{.Model}}
+ {{if .FragmentID}} +
FragmentID:
{{.FragmentID}}
+ {{end}} +
+ +
+ {{if not .IsOnline}} +
+ + {{if eq (len .VisibleSSIDs) 0}} + + {{else}} +
+ +
+ {{end}} +
+ +
+ + +
+ {{end}} + + {{if not .IsConfigured}} +
+ + +
+ {{end}} + + +
+ {{if not .IsOnline}} + + {{end}} +
+{{end}} diff --git a/subsystems/registry/registry.go b/subsystems/registry/registry.go index 108e472..f9c62fc 100644 --- a/subsystems/registry/registry.go +++ b/subsystems/registry/registry.go @@ -13,16 +13,14 @@ import ( var ( mu sync.Mutex creators = map[string]CreatorFunc{} - configs = map[string]*pb.DeviceSubsystemConfig{} ) type CreatorFunc func(ctx context.Context, logger logging.Logger, updateConf *pb.DeviceSubsystemConfig) (subsystems.Subsystem, error) -func Register(name string, creator CreatorFunc, defaultCfg *pb.DeviceSubsystemConfig) { +func Register(name string, creator CreatorFunc) { mu.Lock() defer mu.Unlock() creators[name] = creator - configs[name] = defaultCfg } func Deregister(name string) { @@ -41,16 +39,6 @@ func GetCreator(name string) CreatorFunc { return nil } -func GetDefaultConfig(name string) *pb.DeviceSubsystemConfig { - mu.Lock() - defer mu.Unlock() - cfg, ok := configs[name] - if ok { - return cfg - } - return nil -} - func List() []string { mu.Lock() defer mu.Unlock() diff --git a/subsystems/syscfg/logging.go b/subsystems/syscfg/logging.go new file mode 100644 index 0000000..20e8ef2 --- /dev/null +++ b/subsystems/syscfg/logging.go @@ -0,0 +1,121 @@ +package syscfg + +// This file contains tweaks for logging/journald, such as max size limits. + +import ( + "errors" + "io/fs" + "os" + "os/exec" + "regexp" + + errw "github.com/pkg/errors" + sysd "github.com/sergeymakinen/go-systemdconf/v2" + "github.com/sergeymakinen/go-systemdconf/v2/conf" + "github.com/viamrobotics/agent" +) + +var ( + journaldConfPath = "/etc/systemd/journald.conf.d/90-viam.conf" + defaultLogLimit = "512M" +) + +type LogConfig struct { + Disable bool `json:"disable"` + SystemMaxUse string `json:"system_max_use"` + RuntimeMaxUse string `json:"runtime_max_use"` +} + +func (s *syscfg) EnforceLogging() error { + s.mu.RLock() + cfg := s.cfg.Logging + s.mu.RUnlock() + if cfg.Disable { + if err := os.Remove(journaldConfPath); err != nil { + if errw.Is(err, fs.ErrNotExist) { + return nil + } + return errw.Wrapf(err, "deleting %s", journaldConfPath) + } + + // if journald is NOT enabled, simply return + //nolint:nilerr + if err := checkJournaldEnabled(); err != nil { + return nil + } + + if err := restartJournald(); err != nil { + return err + } + s.logger.Infof("Logging config disabled. Removing customized %s", journaldConfPath) + return nil + } + + if err := checkJournaldEnabled(); err != nil { + s.logger.Warn("systemd-journald is not enabled, cannot configure logging limits") + return err + } + + persistSize := cfg.SystemMaxUse + tempSize := cfg.RuntimeMaxUse + + if persistSize == "" { + persistSize = defaultLogLimit + } + + if tempSize == "" { + tempSize = defaultLogLimit + } + + sizeRegEx := regexp.MustCompile(`^[0-9]+[KMGTPE]$`) + if !(sizeRegEx.MatchString(persistSize) && sizeRegEx.MatchString(tempSize)) { + return errw.New("logfile size limits must be specificed in bytes, with one optional suffix character [KMGTPE]") + } + + journalConf := &conf.JournaldFile{ + Journal: conf.JournaldJournalSection{ + SystemMaxUse: sysd.Value{persistSize}, + RuntimeMaxUse: sysd.Value{tempSize}, + }, + } + + newFileBytes, err := sysd.Marshal(journalConf) + if err != nil { + return errw.Wrapf(err, "marshaling new file for %s", journaldConfPath) + } + + isNew, err1 := agent.WriteFileIfNew(journaldConfPath, newFileBytes) + if err1 != nil { + // We may have written a corrupt file, try to remove to salvage at least default behavior. + if err := os.RemoveAll(journaldConfPath); err != nil { + return errors.Join(err1, errw.Wrapf(err, "deleting %s", journaldConfPath)) + } + return err1 + } + + if isNew { + if err := restartJournald(); err != nil { + return err + } + s.logger.Infof("Updated %s, setting SystemMaxUse=%s and RuntimeMaxUse=%s", journaldConfPath, persistSize, tempSize) + } + return nil +} + +func restartJournald() error { + cmd := exec.Command("systemctl", "restart", "systemd-journald") + output, err := cmd.CombinedOutput() + if err != nil { + return errw.Wrapf(err, "executing 'systemctl restart systemd-journald' %s", output) + } + return nil +} + +func checkJournaldEnabled() error { + cmd := exec.Command("systemctl", "is-enabled", "systemd-journald") + output, err := cmd.CombinedOutput() + if err != nil { + return errw.Wrapf(err, "executing 'systemctl is-enabled systemd-journald' %s", output) + } + return nil +} diff --git a/subsystems/syscfg/syscfg.go b/subsystems/syscfg/syscfg.go index 31503d4..50b2660 100644 --- a/subsystems/syscfg/syscfg.go +++ b/subsystems/syscfg/syscfg.go @@ -3,7 +3,11 @@ package syscfg import ( "context" + "errors" + "reflect" + "sync" + errw "github.com/pkg/errors" "github.com/viamrobotics/agent" "github.com/viamrobotics/agent/subsystems" "github.com/viamrobotics/agent/subsystems/registry" @@ -12,26 +16,138 @@ import ( ) func init() { - registry.Register(SubsysName, NewSubsystem, DefaultConfig) + registry.Register(SubsysName, NewSubsystem) } -var ( - Debug = false - DefaultConfig = &pb.DeviceSubsystemConfig{} -) - const ( SubsysName = "agent-syscfg" ) +type Config struct { + Logging LogConfig `json:"logging"` + Upgrades UpgradesConfig `json:"upgrades"` +} + +type syscfg struct { + mu sync.RWMutex + healthy bool + cfg Config + logger logging.Logger + running bool + disabled bool + cancel context.CancelFunc + workers sync.WaitGroup +} + func NewSubsystem(ctx context.Context, logger logging.Logger, updateConf *pb.DeviceSubsystemConfig) (subsystems.Subsystem, error) { - extraArgs := []string{} - if Debug { - extraArgs = []string{"--debug"} - } - is, err := agent.NewInternalSubsystem(SubsysName, extraArgs, logger, true) + cfg, err := agent.ConvertAttributes[Config](updateConf.GetAttributes()) if err != nil { return nil, err } - return agent.NewAgentSubsystem(ctx, SubsysName, logger, is) + + return &syscfg{cfg: *cfg, logger: logger, disabled: updateConf.GetDisable()}, nil +} + +func (s *syscfg) Update(ctx context.Context, cfg *pb.DeviceSubsystemConfig) (bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + + var needRestart bool + if cfg.GetDisable() != s.disabled { + s.disabled = cfg.GetDisable() + needRestart = true + } + + if s.disabled { + return needRestart, nil + } + + newConf, err := agent.ConvertAttributes[Config](cfg.GetAttributes()) + if err != nil { + return needRestart, err + } + + if reflect.DeepEqual(newConf, s.cfg) { + return needRestart, nil + } + + needRestart = true + s.cfg = *newConf + return needRestart, nil +} + +func (s *syscfg) Version() string { + return agent.GetVersion() +} + +func (s *syscfg) Start(ctx context.Context) error { + s.mu.Lock() + defer s.mu.Unlock() + + // prevent double-starts + if s.running { + return errors.New("already running") + } + + if s.disabled { + s.logger.Infof("agent-syscfg disabled") + return agent.ErrSubsystemDisabled + } + + cancelCtx, cancelFunc := context.WithCancel(ctx) + s.cancel = cancelFunc + s.running = true + s.workers.Add(1) + go func() { + var healthyLog, healthyUpgrades bool + defer func() { + // if something panicked, log it and allow things to continue + r := recover() + if r != nil { + s.logger.Error("syscfg subsystem encountered a panic") + s.logger.Error(r) + } + + s.mu.Lock() + s.healthy = healthyLog && healthyUpgrades + s.running = false + s.workers.Done() + s.mu.Unlock() + }() + + // set journald max size limits + err := s.EnforceLogging() + if err != nil { + s.logger.Error(errw.Wrap(err, "configuring journald logging")) + } + healthyLog = true + + // set unattended upgrades + err = s.EnforceUpgrades(cancelCtx) + if err != nil { + s.logger.Error(errw.Wrap(err, "configuring unattended upgrades")) + } + healthyUpgrades = true + }() + + return nil +} + +func (s *syscfg) Stop(ctx context.Context) error { + s.mu.RLock() + if s.cancel != nil { + s.cancel() + } + s.mu.RUnlock() + s.workers.Wait() + return nil +} + +func (s *syscfg) HealthCheck(ctx context.Context) error { + s.mu.RLock() + defer s.mu.RUnlock() + if s.healthy || s.disabled { + return nil + } + return errors.New("healthcheck failed") } diff --git a/subsystems/syscfg/test-apt-policy-debian-bookworm.txt b/subsystems/syscfg/test-apt-policy-debian-bookworm.txt new file mode 100644 index 0000000..0863a28 --- /dev/null +++ b/subsystems/syscfg/test-apt-policy-debian-bookworm.txt @@ -0,0 +1,13 @@ +Package files: + 100 /var/lib/dpkg/status + release a=now + 500 http://deb.debian.org/debian-security bookworm-security/main amd64 Packages + release v=12,o=Debian,a=stable-security,n=bookworm-security,l=Debian-Security,c=main,b=amd64 + origin deb.debian.org + 500 http://deb.debian.org/debian bookworm-updates/main amd64 Packages + release v=12-updates,o=Debian,a=stable-updates,n=bookworm-updates,l=Debian,c=main,b=amd64 + origin deb.debian.org + 500 http://deb.debian.org/debian bookworm/main amd64 Packages + release v=12.6,o=Debian,a=stable,n=bookworm,l=Debian,c=main,b=amd64 + origin deb.debian.org +Pinned packages: diff --git a/subsystems/syscfg/test-apt-policy-ubuntu-jammy.txt b/subsystems/syscfg/test-apt-policy-ubuntu-jammy.txt new file mode 100644 index 0000000..8681eb1 --- /dev/null +++ b/subsystems/syscfg/test-apt-policy-ubuntu-jammy.txt @@ -0,0 +1,46 @@ +Package files: + 100 /var/lib/dpkg/status + release a=now + 500 http://security.ubuntu.com/ubuntu jammy-security/multiverse amd64 Packages + release v=22.04,o=Ubuntu,a=jammy-security,n=jammy,l=Ubuntu,c=multiverse,b=amd64 + origin security.ubuntu.com + 500 http://security.ubuntu.com/ubuntu jammy-security/universe amd64 Packages + release v=22.04,o=Ubuntu,a=jammy-security,n=jammy,l=Ubuntu,c=universe,b=amd64 + origin security.ubuntu.com + 500 http://security.ubuntu.com/ubuntu jammy-security/restricted amd64 Packages + release v=22.04,o=Ubuntu,a=jammy-security,n=jammy,l=Ubuntu,c=restricted,b=amd64 + origin security.ubuntu.com + 500 http://security.ubuntu.com/ubuntu jammy-security/main amd64 Packages + release v=22.04,o=Ubuntu,a=jammy-security,n=jammy,l=Ubuntu,c=main,b=amd64 + origin security.ubuntu.com + 100 http://archive.ubuntu.com/ubuntu jammy-backports/universe amd64 Packages + release v=22.04,o=Ubuntu,a=jammy-backports,n=jammy,l=Ubuntu,c=universe,b=amd64 + origin archive.ubuntu.com + 100 http://archive.ubuntu.com/ubuntu jammy-backports/main amd64 Packages + release v=22.04,o=Ubuntu,a=jammy-backports,n=jammy,l=Ubuntu,c=main,b=amd64 + origin archive.ubuntu.com + 500 http://archive.ubuntu.com/ubuntu jammy-updates/multiverse amd64 Packages + release v=22.04,o=Ubuntu,a=jammy-updates,n=jammy,l=Ubuntu,c=multiverse,b=amd64 + origin archive.ubuntu.com + 500 http://archive.ubuntu.com/ubuntu jammy-updates/universe amd64 Packages + release v=22.04,o=Ubuntu,a=jammy-updates,n=jammy,l=Ubuntu,c=universe,b=amd64 + origin archive.ubuntu.com + 500 http://archive.ubuntu.com/ubuntu jammy-updates/restricted amd64 Packages + release v=22.04,o=Ubuntu,a=jammy-updates,n=jammy,l=Ubuntu,c=restricted,b=amd64 + origin archive.ubuntu.com + 500 http://archive.ubuntu.com/ubuntu jammy-updates/main amd64 Packages + release v=22.04,o=Ubuntu,a=jammy-updates,n=jammy,l=Ubuntu,c=main,b=amd64 + origin archive.ubuntu.com + 500 http://archive.ubuntu.com/ubuntu jammy/multiverse amd64 Packages + release v=22.04,o=Ubuntu,a=jammy,n=jammy,l=Ubuntu,c=multiverse,b=amd64 + origin archive.ubuntu.com + 500 http://archive.ubuntu.com/ubuntu jammy/universe amd64 Packages + release v=22.04,o=Ubuntu,a=jammy,n=jammy,l=Ubuntu,c=universe,b=amd64 + origin archive.ubuntu.com + 500 http://archive.ubuntu.com/ubuntu jammy/restricted amd64 Packages + release v=22.04,o=Ubuntu,a=jammy,n=jammy,l=Ubuntu,c=restricted,b=amd64 + origin archive.ubuntu.com + 500 http://archive.ubuntu.com/ubuntu jammy/main amd64 Packages + release v=22.04,o=Ubuntu,a=jammy,n=jammy,l=Ubuntu,c=main,b=amd64 + origin archive.ubuntu.com +Pinned packages: diff --git a/subsystems/syscfg/upgrades.go b/subsystems/syscfg/upgrades.go new file mode 100644 index 0000000..b33d534 --- /dev/null +++ b/subsystems/syscfg/upgrades.go @@ -0,0 +1,184 @@ +package syscfg + +// This file contains tweaks for enabling/disabling unattended upgrades. + +import ( + "context" + "fmt" + "os" + "os/exec" + "regexp" + "strings" + + errw "github.com/pkg/errors" + "github.com/viamrobotics/agent" +) + +const ( + autoUpgradesPath = "/etc/apt/apt.conf.d/20auto-upgrades" + autoUpgradesContentsEnabled = `APT::Periodic::Update-Package-Lists "1";` + "\n" + `APT::Periodic::Unattended-Upgrade "1";` + "\n" + autoUpgradesContentsDisabled = `APT::Periodic::Update-Package-Lists "1";` + "\n" + `APT::Periodic::Unattended-Upgrade "0";` + "\n" + + unattendedUpgradesPath = "/etc/apt/apt.conf.d/50unattended-upgrades" +) + +type UpgradesConfig struct { + // Type can be + // Empty/missing ("") to make no changes + // "disable" (or "disabled") to disable auto-upgrades + // "security" to enable ONLY security upgrades + // "all" to enable upgrades from all configured sources + Type string `json:"type"` +} + +func (s *syscfg) EnforceUpgrades(ctx context.Context) error { + s.mu.RLock() + cfg := s.cfg.Upgrades + s.mu.RUnlock() + + if cfg.Type == "" { + return nil + } + + err := checkSupportedDistro() + if err != nil { + return err + } + + if cfg.Type == "disable" || cfg.Type == "disabled" { + isNew, err := agent.WriteFileIfNew(autoUpgradesPath, []byte(autoUpgradesContentsDisabled)) + if err != nil { + return err + } + if isNew { + s.logger.Info("Disabled OS auto-upgrades.") + } + return nil + } + + err = verifyInstall() + if err != nil { + err = doInstall(ctx) + if err != nil { + return err + } + } + + securityOnly := cfg.Type == "security" + confContents, err := generateOrigins(securityOnly) + if err != nil { + return err + } + + isNew1, err := agent.WriteFileIfNew(autoUpgradesPath, []byte(autoUpgradesContentsEnabled)) + if err != nil { + return err + } + + isNew2, err := agent.WriteFileIfNew(unattendedUpgradesPath, []byte(confContents)) + if err != nil { + return err + } + + if isNew1 || isNew2 { + if securityOnly { + s.logger.Info("Enabled OS auto-upgrades (security only.)") + } else { + s.logger.Info("Enabled OS auto-upgrades (full.)") + } + } + + err = enableTimer() + if err != nil { + s.logger.Error(err) + } + return nil +} + +func checkSupportedDistro() error { + data, err := os.ReadFile("/etc/os-release") + if err != nil { + return err + } + + if strings.Contains(string(data), "VERSION_CODENAME=bookworm") || strings.Contains(string(data), "VERSION_CODENAME=bullseye") { + return nil + } + + return errw.New("cannot enable automatic upgrades for unknown distro, only support for Debian bullseye and bookworm is available") +} + +// make sure the needed package is installed. +func verifyInstall() error { + cmd := exec.Command("unattended-upgrade", "-h") + output, err := cmd.CombinedOutput() + if err != nil { + return errw.Wrapf(err, "executing 'unattended-upgrade -h' %s", output) + } + return nil +} + +func enableTimer() error { + // enable here + cmd := exec.Command("systemctl", "enable", "apt-daily-upgrade.timer") + output, err := cmd.CombinedOutput() + if err != nil { + return errw.Wrapf(err, "executing 'systemctl enable apt-daily-upgrade.timer' %s", output) + } + return nil +} + +func doInstall(ctx context.Context) error { + cmd := exec.CommandContext(ctx, "apt", "update") + output, err := cmd.CombinedOutput() + if err != nil { + return errw.Wrapf(err, "executing 'apt update' %s", output) + } + + cmd = exec.CommandContext(ctx, "apt", "install", "-y", "unattended-upgrades") + output, err = cmd.CombinedOutput() + if err != nil { + return errw.Wrapf(err, "executing 'apt install -y unattended-upgrades' %s", output) + } + + return nil +} + +// generates the "Origins-Pattern" section of 50unattended-upgrades file. +func generateOrigins(securityOnly bool) (string, error) { + cmd := exec.Command("apt-cache", "policy") + output, err := cmd.CombinedOutput() + if err != nil { + return "", errw.Wrapf(err, "executing 'apt-cache policy' %s", output) + } + + releases := generateOriginsInner(securityOnly, output) + + // generate actual file contents + origins := "Unattended-Upgrade::Origins-Pattern {" + for release := range releases { + origins = fmt.Sprintf("%s\n %s", origins, release) + } + origins = fmt.Sprintf("%s\n};\n", origins) + return origins, nil +} + +// inner transformation logic of generateOrigins for testing. +func generateOriginsInner(securityOnly bool, output []byte) map[string]bool { + releaseRegex := regexp.MustCompile(`release.*o=([^,]+).*n=([^,]+).*`) + matches := releaseRegex.FindAllStringSubmatch(string(output), -1) + + // use map to reduce to unique set + releases := map[string]bool{} + for _, release := range matches { + // we expect at least an origin and a codename from each line + if len(release) != 3 { + continue + } + if securityOnly && !strings.Contains(release[2], "security") { + continue + } + releases[fmt.Sprintf(`"origin=%s,codename=%s";`, release[1], release[2])] = true + } + return releases +} diff --git a/subsystems/syscfg/upgrades_test.go b/subsystems/syscfg/upgrades_test.go new file mode 100644 index 0000000..cd4b86e --- /dev/null +++ b/subsystems/syscfg/upgrades_test.go @@ -0,0 +1,33 @@ +package syscfg + +import ( + "os" + "testing" + + "go.viam.com/test" +) + +func TestGenerateOrigins(t *testing.T) { + t.Run("debian", func(t *testing.T) { + contents, err := os.ReadFile("test-apt-policy-debian-bookworm.txt") + test.That(t, err, test.ShouldBeNil) + originsAll := generateOriginsInner(false, contents) + test.That(t, originsAll, test.ShouldResemble, map[string]bool{ + `"origin=Debian,codename=bookworm";`: true, + `"origin=Debian,codename=bookworm-security";`: true, + `"origin=Debian,codename=bookworm-updates";`: true, + }) + originsSecurity := generateOriginsInner(true, contents) + test.That(t, originsSecurity, test.ShouldResemble, map[string]bool{ + `"origin=Debian,codename=bookworm-security";`: true, + }) + }) + + t.Run("ubuntu", func(t *testing.T) { + t.Skip("todo: ubuntu parsing") + contents, err := os.ReadFile("test-apt-policy-ubuntu-jammy.txt") + test.That(t, err, test.ShouldBeNil) + generateOriginsInner(false, contents) + generateOriginsInner(true, contents) + }) +} diff --git a/subsystems/viamagent/viam-agent.service b/subsystems/viamagent/viam-agent.service index a0d5204..eec597a 100644 --- a/subsystems/viamagent/viam-agent.service +++ b/subsystems/viamagent/viam-agent.service @@ -1,6 +1,6 @@ [Unit] Description=Viam Services Agent -Wants=NetworkManager.service +After=NetworkManager.service StartLimitIntervalSec=0 [Service] diff --git a/subsystems/viamagent/viamagent.go b/subsystems/viamagent/viamagent.go index 416e0f8..5045035 100644 --- a/subsystems/viamagent/viamagent.go +++ b/subsystems/viamagent/viamagent.go @@ -21,7 +21,7 @@ import ( ) func init() { - registry.Register(subsysName, NewSubsystem, DefaultConfig) + registry.Register(subsysName, NewSubsystem) } const ( @@ -38,7 +38,6 @@ var ( //go:embed viam-agent.service serviceFileContents []byte - DefaultConfig = &pb.DeviceSubsystemConfig{} ) type agentSubsystem struct{} @@ -84,22 +83,6 @@ func (a *agentSubsystem) Update(ctx context.Context, cfg *pb.DeviceSubsystemConf return true, nil } -// GetVersion returns the version embedded at build time. -func GetVersion() string { - if Version == "" { - return "custom" - } - return Version -} - -// GetRevision returns the git revision embedded at build time. -func GetRevision() string { - if GitRevision == "" { - return "unknown" - } - return GitRevision -} - func Install(logger logging.Logger) error { // Check for systemd cmd := exec.Command("systemctl", "--version") diff --git a/subsystems/viamserver/viamserver.go b/subsystems/viamserver/viamserver.go index e578049..420deab 100644 --- a/subsystems/viamserver/viamserver.go +++ b/subsystems/viamserver/viamserver.go @@ -26,7 +26,7 @@ import ( func init() { globalConfig.Store(&viamServerConfig{startTimeout: defaultStartTimeout}) - registry.Register(SubsysName, NewSubsystem, DefaultConfig) + registry.Register(SubsysName, NewSubsystem) } type viamServerConfig struct { @@ -44,7 +44,6 @@ const ( var ( ConfigFilePath = "/etc/viam.json" - DefaultConfig = &pb.DeviceSubsystemConfig{} // Set if (cached or cloud) config has the "fast_start" attribute set on the viam-server subsystem. FastStart atomic.Bool @@ -299,7 +298,7 @@ func (s *viamServer) Update(ctx context.Context, cfg *pb.DeviceSubsystemConfig, s.mu.Lock() defer s.mu.Unlock() setFastStart(cfg) - if newVersion { + if newVersion && s.running { s.logger.Info("awaiting user restart to run new viam-server version") s.shouldRun = false } diff --git a/utils.go b/utils.go index c71f6c8..53ce414 100644 --- a/utils.go +++ b/utils.go @@ -3,8 +3,10 @@ package agent import ( "bufio" + "bytes" "context" "crypto/sha256" + "encoding/json" "errors" "io" "io/fs" @@ -21,9 +23,32 @@ import ( errw "github.com/pkg/errors" "github.com/ulikunitz/xz" "golang.org/x/sys/unix" + "google.golang.org/protobuf/types/known/structpb" ) -var ViamDirs = map[string]string{"viam": "/opt/viam"} +var ( + // versions embedded at build time. + Version = "" + GitRevision = "" + + ViamDirs = map[string]string{"viam": "/opt/viam"} +) + +// GetVersion returns the version embedded at build time. +func GetVersion() string { + if Version == "" { + return "custom" + } + return Version +} + +// GetRevision returns the git revision embedded at build time. +func GetRevision() string { + if GitRevision == "" { + return "unknown" + } + return GitRevision +} func init() { ViamDirs["bin"] = filepath.Join(ViamDirs["viam"], "bin") @@ -269,3 +294,50 @@ func SyncFS(syncPath string) (errRet error) { } return errors.Join(errRet, file.Close()) } + +func WriteFileIfNew(outPath string, data []byte) (bool, error) { + //nolint:gosec + curFileBytes, err := os.ReadFile(outPath) + if err != nil { + if !errw.Is(err, fs.ErrNotExist) { + return false, errw.Wrapf(err, "opening %s for reading", outPath) + } + } else if bytes.Equal(curFileBytes, data) { + return false, nil + } + + //nolint:gosec + if err := os.MkdirAll(path.Dir(outPath), 0o755); err != nil { + return true, errw.Wrapf(err, "creating directory for %s", outPath) + } + + //nolint:gosec + if err := os.WriteFile(outPath, data, 0o644); err != nil { + return true, errw.Wrapf(err, "writing %s", outPath) + } + + return true, nil +} + +func ConvertAttributes[T any](attributes *structpb.Struct) (*T, error) { + jsonBytes, err := attributes.MarshalJSON() + if err != nil { + return new(T), err + } + + newConfig := new(T) + if err = json.Unmarshal(jsonBytes, newConfig); err != nil { + return new(T), err + } + + return newConfig, nil +} + +func Sleep(ctx context.Context, timeout time.Duration) bool { + select { + case <-ctx.Done(): + return false + case <-time.After(timeout): + return true + } +}