diff --git a/install.sh b/install.sh index 6adb67a..9e707c1 100755 --- a/install.sh +++ b/install.sh @@ -68,7 +68,7 @@ fetch_config() { fi } -# Verifies that NetworkManager is 1.42 or newer. +# Verifies that NetworkManager is 1.30 or newer. check_nm_version() { which nmcli >/dev/null 2>&1 || return 1 @@ -77,7 +77,7 @@ check_nm_version() { NM_VERSION_MAJOR=$(echo $NM_VERSION | cut -d. -f1) NM_VERSION_MINOR=$(echo $NM_VERSION | cut -d. -f2) - if [ $NM_VERSION_MAJOR -ge 1 ] && [ $NM_VERSION_MINOR -ge 42 ]; then + if [ $NM_VERSION_MAJOR -ge 1 ] && [ $NM_VERSION_MINOR -ge 30 ]; then return 0 fi @@ -139,7 +139,7 @@ enable_networkmanager() { systemctl is-enabled NetworkManager && check_nm_version && return echo - echo "Viam provides a wifi management and device provisioning service. To use it, NetworkManager 1.42 (or newer) must be installed and active." + echo "Viam provides a wifi management and device provisioning service. To use it, NetworkManager 1.30 (or newer) must be installed and active." if check_nm_version || is_bullseye; then # We can automate this. @@ -177,7 +177,7 @@ enable_networkmanager() { fi if systemctl cat NetworkManager >/dev/null; then - systemctl enable --now NetworkManager || (echo "Failed to active NetworkManager" && return 1) + systemctl enable --now NetworkManager || (echo "Failed to activate NetworkManager" && return 1) systemctl disable dhcpcd else return 1 diff --git a/subsystems/provisioning/definitions.go b/subsystems/provisioning/definitions.go index 941e73d..d838743 100644 --- a/subsystems/provisioning/definitions.go +++ b/subsystems/provisioning/definitions.go @@ -42,17 +42,18 @@ const ( 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{}, + 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), + DeviceRebootAfterOfflineMinutes: Timeout(0), + Networks: []NetworkConfig{}, } // Can be overwritten via cli arguments. @@ -63,6 +64,7 @@ var ( 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 + scanTimeout = time.Second * 30 connectTimeout = time.Second * 50 // longer than the 45 second timeout in NetworkManager ) @@ -298,9 +300,11 @@ func ConfigFromJSON(defaultConf Config, jsonBytes []byte) (*Config, error) { } if conf.DeviceRebootAfterOfflineMinutes != 0 && - conf.DeviceRebootAfterOfflineMinutes < conf.OfflineTimeout || - conf.DeviceRebootAfterOfflineMinutes < conf.UserTimeout { - return &conf, errw.Errorf("device_reboot_after_offline_minutes cannot be less than offline_timeout or user_timeout") + (conf.DeviceRebootAfterOfflineMinutes < conf.OfflineTimeout || conf.DeviceRebootAfterOfflineMinutes < conf.UserTimeout) { + badOffline := conf.DeviceRebootAfterOfflineMinutes + conf.DeviceRebootAfterOfflineMinutes = defaultConf.DeviceRebootAfterOfflineMinutes + return &conf, errw.Errorf("device_reboot_after_offline_minutes (%s) cannot be less than offline_timeout (%s) or user_timeout (%s)", + time.Duration(badOffline), time.Duration(conf.OfflineTimeout), time.Duration(conf.UserTimeout)) } return &conf, nil @@ -382,7 +386,7 @@ type Config struct { DeviceRebootAfterOfflineMinutes Timeout `json:"device_reboot_after_offline_minutes"` } -// Timeout allows parsing golang-style durations (1h20m30s) OR seconds-as-float from/to json. +// Timeout allows parsing golang-style durations (1h20m30s) OR minutes-as-float from/to json. type Timeout time.Duration func (t Timeout) MarshalJSON() ([]byte, error) { diff --git a/subsystems/provisioning/networkmanager.go b/subsystems/provisioning/networkmanager.go index 11d791f..572276d 100644 --- a/subsystems/provisioning/networkmanager.go +++ b/subsystems/provisioning/networkmanager.go @@ -44,7 +44,10 @@ func (w *Provisioning) warnIfMultiplePrimaryNetworks() { 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 { + // note this does NOT use VisibleNetworkTimeout (like getCandidates does) + recentlySeen := nw.lastSeen.After(w.connState.getProvisioningChange().Add(time.Duration(w.Config().OfflineTimeout * -2))) + + if !nw.isHotspot && recentlySeen { visible = append(visible, nw.getInfo()) } } @@ -510,7 +513,7 @@ func (w *Provisioning) getCandidates(ifName string) []string { continue } // ssid seen within the past minute - visible := nw.lastSeen.After(time.Now().Add(time.Minute * -1)) + visible := nw.lastSeen.After(time.Now().Add(VisibleNetworkTimeout * -1)) // ssid has a connection known to network manager configured := nw.conn != nil @@ -555,6 +558,9 @@ func (w *Provisioning) backgroundLoop(ctx context.Context, scanChan chan<- bool) if err := w.networkScan(ctx); err != nil { w.logger.Error(err) } + if err := w.updateKnownConnections(ctx); err != nil { + w.logger.Error(err) + } if err := w.checkConnections(); err != nil { w.logger.Error(err) } @@ -656,9 +662,9 @@ func (w *Provisioning) mainLoop(ctx context.Context) { } } case <-scanChan: - case <-time.After(scanLoopDelay * 4): + case <-time.After((scanLoopDelay + scanTimeout) * 2): // safety fallback if something hangs - w.logger.Warn("wifi scan has not completed for %s", scanLoopDelay*5) + w.logger.Warnf("wifi scan has not completed for %s", (scanLoopDelay+scanTimeout)*2) } w.mainLoopHealth.MarkGood() @@ -741,7 +747,7 @@ func (w *Provisioning) mainLoop(ctx context.Context) { offlineRebootTimeout := w.cfg.DeviceRebootAfterOfflineMinutes > 0 && lastConnectivity.Before(now.Add(time.Duration(w.cfg.DeviceRebootAfterOfflineMinutes)*-1)) if offlineRebootTimeout { - w.logger.Infof("device has been offline for more than %s minutes, rebooting", w.cfg.DeviceRebootAfterOfflineMinutes) + w.logger.Infof("device has been offline for more than %s, rebooting", time.Duration(w.cfg.DeviceRebootAfterOfflineMinutes)) cmd := exec.Command("systemctl", "reboot") output, err := cmd.CombinedOutput() if err != nil { diff --git a/subsystems/provisioning/networkstate.go b/subsystems/provisioning/networkstate.go index 76a7e8b..62fd3b9 100644 --- a/subsystems/provisioning/networkstate.go +++ b/subsystems/provisioning/networkstate.go @@ -15,6 +15,10 @@ type networkState struct { // the wifi interface to default to when no interface is specified hotspotInterface string + // these variables track and disable the scan-in-hotspot functionality + scanFailCount uint + noScanInHotspot bool + // 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 @@ -43,6 +47,41 @@ func NewNetworkState(logger logging.Logger) *networkState { } } +func (n *networkState) NoScanInHotspot() bool { + n.mu.Lock() + defer n.mu.Unlock() + return n.noScanInHotspot +} + +func (n *networkState) SetNoScanInHotspot(noScan bool) { + n.mu.Lock() + defer n.mu.Unlock() + n.noScanInHotspot = noScan +} + +func (n *networkState) IncrementFailScan() { + n.mu.Lock() + defer n.mu.Unlock() + n.scanFailCount++ + if n.scanFailCount >= 3 { + n.noScanInHotspot = true + n.logger.Warn("Device hardware/software does not appear to support wifi scanning while hotspot is active. " + + "Further scanning will be disabled while in hotspot mode. Relying on fallback timeout to exit hotspot mode and allow rescans.") + } +} + +func (n *networkState) FailScan() uint { + n.mu.Lock() + defer n.mu.Unlock() + return n.scanFailCount +} + +func (n *networkState) ResetFailScan() { + n.mu.Lock() + defer n.mu.Unlock() + n.scanFailCount = 0 +} + func (n *networkState) SetHotspotInterface(iface string) { n.mu.Lock() defer n.mu.Unlock() diff --git a/subsystems/provisioning/provisioning.go b/subsystems/provisioning/provisioning.go index a40ef5f..d1aed66 100644 --- a/subsystems/provisioning/provisioning.go +++ b/subsystems/provisioning/provisioning.go @@ -3,6 +3,7 @@ package provisioning import ( "context" + "errors" "net/http" "reflect" "strings" @@ -92,22 +93,18 @@ func NewProvisioning(ctx context.Context, logger logging.Logger, updateConf *age } func (w *Provisioning) getNM() (gnm.NetworkManager, error) { - nmErr := errw.New("NetworkManager does not appear to be responding as expected. " + - "Please ensure NetworkManger >= v1.42 is installed and enabled. Disabling agent-provisioning until next restart.") - wifiErr := errw.New("No WiFi devices available. Disabling agent-provisioning until next restart.") - nm, err := gnm.NewNetworkManager() if err != nil { w.noNM = true w.logger.Error(err) - return nil, nmErr + return nil, ErrNM } ver, err := nm.GetPropertyVersion() if err != nil { w.noNM = true w.logger.Error(err) - return nil, nmErr + return nil, ErrNM } w.logger.Infof("Found NetworkManager version: %s", ver) @@ -116,24 +113,28 @@ func (w *Provisioning) getNM() (gnm.NetworkManager, error) { if err != nil { w.noNM = true w.logger.Error(err) - return nil, nmErr + return nil, ErrNM } - if !sv.GreaterThanEqual(semver.MustParse("1.42.0")) { + if !sv.GreaterThanEqual(semver.MustParse("1.30.0")) { w.noNM = true - return nil, nmErr + return nil, ErrNM } - flags, err := nm.GetPropertyRadioFlags() - if err != nil { - w.noNM = true - w.logger.Error(err) - return nil, wifiErr - } + // Bail out here early if we can't find a wifi radio + // Older versions will bail out during initDevices() if scan fails to find a wifi interface + if sv.GreaterThanEqual(semver.MustParse("1.38.0")) { + flags, err := nm.GetPropertyRadioFlags() + if err != nil { + w.noNM = true + w.logger.Error(err) + return nil, ErrNoWifi + } - if flags&gnm.NmRadioFlagsWlanAvailable != gnm.NmRadioFlagsWlanAvailable { - w.noNM = true - return nil, wifiErr + if flags&gnm.NmRadioFlagsWlanAvailable != gnm.NmRadioFlagsWlanAvailable { + w.noNM = true + return nil, ErrNoWifi + } } return nm, nil @@ -177,6 +178,9 @@ func (w *Provisioning) init(ctx context.Context) error { } if err := w.initDevices(); err != nil { + if errors.Is(err, ErrNoWifi) { + w.noNM = true + } return err } @@ -184,6 +188,9 @@ func (w *Provisioning) init(ctx context.Context) error { if err := w.networkScan(ctx); err != nil { w.logger.Error(err) } + if err := w.updateKnownConnections(ctx); err != nil { + w.logger.Error(err) + } w.warnIfMultiplePrimaryNetworks() diff --git a/subsystems/provisioning/scanning.go b/subsystems/provisioning/scanning.go index f34190a..5eeaf13 100644 --- a/subsystems/provisioning/scanning.go +++ b/subsystems/provisioning/scanning.go @@ -11,7 +11,18 @@ import ( errw "github.com/pkg/errors" ) +var ( + ErrScanTimeout = errw.New("wifi scanning timed out") + + // how long is a scanned network "visible" for candidate selection? + VisibleNetworkTimeout = time.Minute +) + func (w *Provisioning) networkScan(ctx context.Context) error { + if w.connState.getProvisioning() && w.netState.NoScanInHotspot() { + return nil + } + wifiDev := w.netState.WifiDevice(w.Config().HotspotInterface) if wifiDev == nil { return errw.Errorf("cannot find hotspot interface: %s", w.Config().HotspotInterface) @@ -27,18 +38,27 @@ func (w *Provisioning) networkScan(ctx context.Context) error { return errw.Wrap(err, "scanning wifi") } - var lastScan int64 + scanDeadline := time.Now().Add(scanTimeout) for { - lastScan, err = wifiDev.GetPropertyLastScan() + lastScan, err := wifiDev.GetPropertyLastScan() if err != nil { return errw.Wrap(err, "scanning wifi") } if lastScan > prevScan { + if w.connState.getProvisioning() { + w.netState.ResetFailScan() + } break } if !w.bgLoopHealth.Sleep(ctx, time.Second) { return nil } + if time.Now().After(scanDeadline) { + if w.connState.getProvisioning() { + w.netState.IncrementFailScan() + } + return ErrScanTimeout + } } wifiList, err := wifiDev.GetAccessPoints() @@ -109,14 +129,14 @@ func (w *Provisioning) networkScan(ctx context.Context) error { } nw.mu.Lock() // if a network isn't visible, reset the times so we'll retry if it comes back - if nw.lastSeen.Before(time.Now().Add(time.Minute * -1)) { + if nw.lastSeen.Before(time.Now().Add(VisibleNetworkTimeout * -1)) { nw.firstSeen = time.Time{} nw.lastTried = time.Time{} } nw.mu.Unlock() } - return w.updateKnownConnections(ctx) + return nil } func parseWPAFlags(apFlags, wpaFlags, rsnFlags uint32) string { diff --git a/subsystems/provisioning/setup.go b/subsystems/provisioning/setup.go index 1cc7700..4295730 100644 --- a/subsystems/provisioning/setup.go +++ b/subsystems/provisioning/setup.go @@ -14,6 +14,12 @@ import ( errw "github.com/pkg/errors" ) +var ( + ErrNM = errw.New("NetworkManager does not appear to be responding as expected. " + + "Please ensure NetworkManger >= v1.30 is installed and enabled. Disabling agent-provisioning until next restart.") + ErrNoWifi = errw.New("No WiFi devices available. Disabling agent-provisioning until next restart.") +) + func (w *Provisioning) writeDNSMasq() error { DNSMasqContents := DNSMasqContentsRedirect if w.cfg.DisableDNSRedirect { @@ -137,7 +143,7 @@ func (w *Provisioning) initDevices() error { } if w.cfg.HotspotInterface == "" { - return errors.New("cannot find wifi device for provisioning/hotspot") + return ErrNoWifi } return nil